diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b3fdc1b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Normalize line endings to LF in the repository and working tree. csdd runs on +# Windows/WSL where autocrlf otherwise makes the entire tree show as Modified and +# breaks gofmt/line-ending-sensitive checks on Windows CI. LF everywhere keeps +# manifest hashes, Go source, and shell hooks consistent across platforms. +* text=auto eol=lf + +# Go, docs, shell hooks, and config are always LF. +*.go text eol=lf +*.mod text eol=lf +*.sum text eol=lf +*.sh text eol=lf +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.tmpl text eol=lf +*.ts text eol=lf +*.mjs text eol=lf +*.js text eol=lf + +# Binary assets must never be line-ending converted. +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.gz binary +*.zip binary +*.woff binary +*.woff2 binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3da285e..610e546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,92 +1,142 @@ -name: ci - -# Test + validate gate. Runs on: -# - every pull request — validate changes before they can merge. -# - push to main — validate the merge result on the default branch, since the -# release workflow is now manual (workflow_dispatch) and no longer runs on -# push, so a merge would otherwise go unverified. -# - workflow_call — reusable, so the manual release pipeline requires this gate -# before building or publishing any binaries. -on: - push: - branches: [main] - pull_request: - workflow_call: - -permissions: - contents: read - -jobs: - test: - name: test & validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - cache: true - - - name: gofmt - run: | - unformatted="$(gofmt -l .)" - if [ -n "$unformatted" ]; then - echo "::error::these files are not gofmt-clean:" - echo "$unformatted" - exit 1 - fi - - - name: go vet - run: go vet ./... - - - name: build - run: go build ./... - - - name: test (race + coverage) - run: go test -race -coverprofile=coverage.out ./... - - web: - name: web dashboard (build) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: internal/web/frontend/package-lock.json - - - name: install - run: npm --prefix internal/web/frontend ci - - # The dashboard (internal/web/dist) is a build artifact, not committed. - # This verifies it still builds; the release pipeline builds it for real - # before cross-compiling so released binaries embed the UI. - - name: build dashboard - run: npm --prefix internal/web/frontend run build - - mcp-server: - name: mcp-server (build & test) - runs-on: ubuntu-latest - defaults: - run: - working-directory: mcp-server - steps: - - uses: actions/checkout@v4 - - # Node 24: the unit tests are TypeScript run through node:test with native - # type stripping (Node >= 22.18). The published package ships compiled JS - # (dist/) and still supports Node >= 18 — see package.json "engines". - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: npm - cache-dependency-path: mcp-server/package-lock.json - - - name: install - run: npm ci - - - name: test (build + node:test) - run: npm test +name: ci + +# Test + validate gate. Runs on: +# - every pull request — validate changes before they can merge. +# - push to main — validate the merge result on the default branch, since the +# release workflow is now manual (workflow_dispatch) and no longer runs on +# push, so a merge would otherwise go unverified. +# - workflow_call — reusable, so the manual release pipeline requires this gate +# before building or publishing any binaries. +on: + push: + branches: [main] + pull_request: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }}) + # Windows is a first-class target and the tool is path/line-ending heavy, so + # exercise the suite on all three OSes. fail-fast is off so one platform's + # failure still lets the others report. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + # Build/test with a current, patched toolchain even though go.mod keeps + # its language directive at 1.22 for install-compatibility. Do NOT switch + # this to go-version-file: go.mod. + go-version: '1.25' + cache: true + + - name: go vet + run: go vet ./... + + - name: build + run: go build ./... + + # The race detector needs a C toolchain + external linker, which the + # Windows runners don't provide out of the box, so run race+coverage on + # Linux/macOS and a plain (still cgo-free) test pass on Windows. + - name: test (race + coverage) + if: runner.os != 'Windows' + run: go test -race -coverprofile=coverage.out ./... + + - name: test (coverage, no race) + if: runner.os == 'Windows' + # Force bash so the coverprofile arg parses like Linux/macOS; the Windows + # runner's default PowerShell mangles `-coverprofile=coverage.out ./...` + # into a bogus `.out` package pattern. + shell: bash + run: go test -coverprofile=coverage.out ./... + + lint: + name: lint & vulncheck + # gofmt and the static analyzers only need to run once; keep them on Linux + # (the Windows runner checks code out with CRLF endings, which gofmt -l would + # flag on every file). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: '1.25' + cache: true + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error::these files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + # Scan the module (and its dependencies) for known-vulnerable, *reachable* + # code paths. This is meant to fail the build — shipping binaries built on a + # flagged stdlib/dep is exactly what we're guarding against. + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + "$(go env GOPATH)/bin/govulncheck" ./... + + # golangci-lint runs the conservative set configured in .golangci.yml. + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.12.2 + + web: + name: web dashboard (build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: internal/web/frontend/package-lock.json + + - name: install + run: npm --prefix internal/web/frontend ci + + # The dashboard (internal/web/dist) is a build artifact, not committed. + # This verifies it still builds; the release pipeline builds it for real + # before cross-compiling so released binaries embed the UI. + - name: build dashboard + run: npm --prefix internal/web/frontend run build + + mcp-server: + name: mcp-server (build & test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp-server + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + # Node 24: the unit tests are TypeScript run through node:test with native + # type stripping (Node >= 22.18). The published package ships compiled JS + # (dist/) and still supports Node >= 18 — see package.json "engines". + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: mcp-server/package-lock.json + + - name: install + run: npm ci + + - name: test (build + node:test) + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d43892..b593d8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,182 +1,212 @@ -name: release - -# Manual, versioned release — the only way a new version ships. -# -# Trigger from the Actions tab ("Run workflow") or: -# gh workflow run release.yml --field version=v0.1.3 -# (or: make release VERSION=v0.1.3) -# -# It runs the full pipeline for the given version: CI gate → cross-platform -# binaries → publish every npm package (the CLI launcher + 5 platform binaries -# + the MCP server, all at the same version) → tag the commit + GitHub Release. -# Nothing is released on push; releasing is always a deliberate manual action. -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (vX.Y.Z)' - required: true - type: string - -permissions: - contents: write - -jobs: - validate: - name: validate version - runs-on: ubuntu-latest - steps: - - name: Check format - env: - VERSION: ${{ inputs.version }} - run: | - case "$VERSION" in - v[0-9]*.[0-9]*.[0-9]*) echo "releasing $VERSION" ;; - *) echo "::error::version must look like vX.Y.Z (got '$VERSION')"; exit 1 ;; - esac - - # Reuse the full CI gate (gofmt, vet, build, race tests + mcp-server tests) — - # no binaries are built or published unless this passes. - ci: - needs: validate - uses: ./.github/workflows/ci.yml - - build: - name: build ${{ matrix.goos }}/${{ matrix.goarch }} - needs: ci - runs-on: ubuntu-latest - strategy: - fail-fast: true - matrix: - include: - - { goos: linux, goarch: amd64 } - - { goos: linux, goarch: arm64 } - - { goos: darwin, goarch: amd64 } - - { goos: darwin, goarch: arm64 } - - { goos: windows, goarch: amd64 } - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - cache: true - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: internal/web/frontend/package-lock.json - - # Build the dashboard into internal/web/dist so //go:embed picks up the - # real UI (the bundle is not committed). Must run before `go build`. - - name: Build web dashboard - run: | - npm --prefix internal/web/frontend ci - npm --prefix internal/web/frontend run build - - - name: Build & package - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: '0' - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - bin=csdd - [ "$GOOS" = "windows" ] && bin=csdd.exe - mkdir -p dist - go build -trimpath \ - -ldflags "-s -w -X github.com/protonspy/csdd/internal/cli.version=${VERSION}" \ - -o "dist/${bin}" ./cmd/csdd - name="csdd_${VERSION}_${GOOS}_${GOARCH}" - cd dist - if [ "$GOOS" = "windows" ]; then - zip -q "${name}.zip" "${bin}" - else - tar -czf "${name}.tar.gz" "${bin}" - fi - rm -f "${bin}" - - - uses: actions/upload-artifact@v4 - with: - name: csdd-${{ matrix.goos }}-${{ matrix.goarch }} - path: dist/* - retention-days: 7 - - # Publish every npm package at the requested version. Authenticates with the - # NPM_TOKEN secret (must be an Automation token, which bypasses 2FA); - # id-token: write lets --provenance attach a signed build attestation. - npm-publish: - name: publish npm packages - needs: build - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write # required for npm --provenance attestation - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - registry-url: 'https://registry.npmjs.org' - - # Trusted publishing requires npm >= 11.5.1 (newer than what ships with Node). - - name: Upgrade npm - run: npm install -g npm@latest - - - uses: actions/download-artifact@v4 - with: - path: artifacts - merge-multiple: true - - # The MCP server ships in lockstep with the CLI; build-packages.mjs stamps - # its version + optionalDependencies and stages it as npm/dist/csdd-mcp. - - name: Build MCP server - run: | - npm --prefix mcp-server ci - npm --prefix mcp-server run build - - - name: Assemble npm packages - env: - VERSION: ${{ inputs.version }} - run: node npm/scripts/build-packages.mjs "$VERSION" artifacts - - - name: Publish (platform packages + mcp-server first, then the root) - env: - # Automation token (bypasses 2FA). setup-node wrote an .npmrc that - # reads this. --provenance still attaches a signed attestation via the - # job's id-token: write permission. - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - set -euo pipefail - # csdd-*/ covers the 5 platform binaries and csdd-mcp; csdd/ is the root launcher. - for d in npm/dist/csdd-*/; do - npm publish "$d" --access public --provenance - done - npm publish npm/dist/csdd --access public --provenance - - # Tag the released commit and create the GitHub Release — only after npm - # publishing succeeds, so a failed publish never leaves a dangling tag/release. - release: - name: tag + GitHub release - needs: npm-publish - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v4 - with: - path: dist - merge-multiple: true - - - name: Checksums - run: cd dist && sha256sum * | tee checksums.txt - - - name: Create tag + release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ inputs.version }} - name: ${{ inputs.version }} - target_commitish: ${{ github.sha }} - make_latest: 'true' - files: dist/* +name: release + +# Manual, versioned release — the only way a new version ships. +# +# Trigger from the Actions tab ("Run workflow") or: +# gh workflow run release.yml --field version=v0.1.3 +# (or: make release VERSION=v0.1.3) +# +# It runs the full pipeline for the given version: CI gate → cross-platform +# binaries → publish every npm package (the CLI launcher + 5 platform binaries +# + the MCP server, all at the same version) → tag the commit + GitHub Release. +# Nothing is released on push; releasing is always a deliberate manual action. +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (vX.Y.Z)' + required: true + type: string + +# Least privilege at the workflow level: only the final "release" job needs to +# write (create the tag + GitHub Release). validate/build/npm-publish keep the +# read-only default and override to what they actually need. +permissions: + contents: read + +jobs: + validate: + name: validate version + runs-on: ubuntu-latest + steps: + - name: Check format + env: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + v[0-9]*.[0-9]*.[0-9]*) echo "releasing $VERSION" ;; + *) echo "::error::version must look like vX.Y.Z (got '$VERSION')"; exit 1 ;; + esac + + # Reuse the full CI gate (gofmt, vet, build, race tests + mcp-server tests) — + # no binaries are built or published unless this passes. + ci: + needs: validate + uses: ./.github/workflows/ci.yml + + build: + name: build ${{ matrix.goos }}/${{ matrix.goarch }} + needs: ci + runs-on: ubuntu-latest + # Cross-compile only; no write access needed. Keep the read-only default + # (inherited from the workflow-level permissions above) explicit here. + permissions: + contents: read + strategy: + fail-fast: true + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + # Ship binaries built with a current, patched toolchain (go.mod stays at + # 1.22 for install-compatibility — do NOT switch to go-version-file). + go-version: '1.25' + cache: true + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: internal/web/frontend/package-lock.json + + # Build the dashboard into internal/web/dist so //go:embed picks up the + # real UI (the bundle is not committed). Must run before `go build`. + - name: Build web dashboard + run: | + npm --prefix internal/web/frontend ci + npm --prefix internal/web/frontend run build + + - name: Build & package + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + bin=csdd + [ "$GOOS" = "windows" ] && bin=csdd.exe + mkdir -p dist + go build -trimpath \ + -ldflags "-s -w -X github.com/protonspy/csdd/internal/cli.version=${VERSION}" \ + -o "dist/${bin}" ./cmd/csdd + name="csdd_${VERSION}_${GOOS}_${GOARCH}" + cd dist + if [ "$GOOS" = "windows" ]; then + zip -q "${name}.zip" "${bin}" + else + tar -czf "${name}.tar.gz" "${bin}" + fi + rm -f "${bin}" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: csdd-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/* + retention-days: 7 + + # Publish every npm package at the requested version. Authenticates with the + # NPM_TOKEN secret (must be an Automation token, which bypasses 2FA); + # id-token: write lets --provenance attach a signed build attestation. + npm-publish: + name: publish npm packages + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for npm --provenance attestation + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + # Trusted publishing requires npm >= 11.5.1 (newer than what ships with + # Node). Pin the major line so the release toolchain is reproducible. + - name: Upgrade npm + run: npm install -g npm@11 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: artifacts + merge-multiple: true + + # The MCP server ships in lockstep with the CLI; build-packages.mjs stamps + # its version + optionalDependencies and stages it as npm/dist/csdd-mcp. + - name: Build MCP server + run: | + npm --prefix mcp-server ci + npm --prefix mcp-server run build + + - name: Assemble npm packages + env: + VERSION: ${{ inputs.version }} + run: node npm/scripts/build-packages.mjs "$VERSION" artifacts + + - name: Publish (platform packages + mcp-server first, then the root) + env: + # Automation token (bypasses 2FA). setup-node wrote an .npmrc that + # reads this. --provenance still attaches a signed attestation via the + # job's id-token: write permission. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + # Idempotent publish: skip any package@version already on the registry so + # re-running after a partial failure can finish instead of hard-failing on + # the first already-published package (mirrors the Makefile's npm-publish). + publish() { + local dir="$1" + local name ver + name=$(node -p "require('./$dir/package.json').name") + ver=$(node -p "require('./$dir/package.json').version") + if npm view "$name@$ver" version >/dev/null 2>&1; then + echo "skip $name@$ver (already published)" + return 0 + fi + echo ">> publishing $name@$ver" + npm publish "$dir" --access public --provenance + } + # csdd-*/ covers the platform binaries and csdd-mcp; csdd/ is the root launcher. + for d in npm/dist/csdd-*/; do + publish "${d%/}" + done + publish npm/dist/csdd + + # Tag the released commit and create the GitHub Release — only after npm + # publishing succeeds, so a failed publish never leaves a dangling tag/release. + release: + name: tag + GitHub release + needs: npm-publish + runs-on: ubuntu-latest + # This is the only job that mutates the repo (creates the tag + Release), so + # scope write access here instead of granting it workflow-wide. + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: dist + merge-multiple: true + + - name: Checksums + run: cd dist && sha256sum * | tee checksums.txt + + - name: Create tag + release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + tag_name: ${{ inputs.version }} + name: ${{ inputs.version }} + target_commitish: ${{ github.sha }} + make_latest: 'true' + files: dist/* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e438eae --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,33 @@ +# golangci-lint configuration (v2 schema — golangci-lint v2.x). +# +# Deliberately conservative: only the standard, high-signal analyzers are +# enabled so CI stays green-friendly instead of drowning in style nits. Add more +# linters here as the codebase is cleaned up. +version: "2" + +linters: + default: none + enable: + - errcheck # unchecked errors + - govet # go vet's analyzers + - ineffassign # assignments never read + - staticcheck # staticcheck (SA/S/ST) suite + - unused # unused code + exclusions: + rules: + # Test code is intentionally lax about unchecked errors (Close/Chdir/…) and + # uses patterns staticcheck false-positives on (e.g. randomToken() == + # randomToken() to assert non-determinism, Hash("a") != Hash("a") to assert + # determinism). + - path: _test\.go + linters: + - errcheck + - staticcheck + # QF1001 (apply De Morgan's law) and S1016 (struct literal -> conversion) + # are style suggestions where the explicit form reads clearer; keep it. + - linters: + - staticcheck + text: "QF1001" + - linters: + - staticcheck + text: "S1016" diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/csdd.iml b/.idea/csdd.iml new file mode 100644 index 0000000..852bacc --- /dev/null +++ b/.idea/csdd.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..1896915 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile index d441a96..b5e84de 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,9 @@ LDFLAGS := -s -w -X $(MODULE)/internal/cli.version=$(VERSION) DIST ?= dist ARTIFACTS ?= $(DIST) -# GOOS/GOARCH targets shipped to npm — must match npm/scripts/build-packages.mjs. -PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 +# GOOS/GOARCH targets shipped to npm — must match npm/scripts/build-packages.mjs +# and the release.yml build matrix. +PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 .DEFAULT_GOAL := help diff --git a/README.md b/README.md index c9f6c39..40720f6 100644 --- a/README.md +++ b/README.md @@ -1,497 +1,497 @@ -# csdd - -**Claude Spec-Driven Development — as an executable contract.** - -`csdd` is a single Go binary that makes **Spec-Driven Development (SDD) + Test-Driven Development (TDD)** the native, *enforced* workflow inside a [Claude Code](https://claude.com/claude-code) repository. It turns the SDD lifecycle — `requirements → design → tasks → implementation` — from "good intentions in markdown" into a contract that is **validated mechanically and gated by human approval at every phase**, so neither a human nor an AI agent can skip a step, ship a requirement without a testable criterion, or mark a parallel task without declaring its boundary. - -It owns five artifact types — **steering** (project memory), **specs** (per-feature contracts), **skills** (executable workflow bundles), **agents** (scoped sub-agents), and **MCP servers** — and is their only sanctioned author, so their structure stays machine-checkable instead of drifting into free-form prose. There's no server and no database: the plain Markdown/JSON files under `.claude/` and `specs/` *are* the API, reviewable in any pull request. - -## Install & run - -**npx — zero install, always the latest.** Fetches the right prebuilt binary for your OS/arch: - -```bash -npx @protonspy/csdd --help # run instantly -npx @protonspy/csdd # no args → interactive TUI -``` - -**Global install** — prefer a short `csdd` on your `PATH`: - -```bash -npm install -g @protonspy/csdd -csdd --help -``` - -**Other ways in:** grab a prebuilt archive from the [releases page](https://github.com/protonspy/csdd/releases) and put `csdd` on your `PATH`, or build from source with `go install github.com/protonspy/csdd@latest`. - -> The examples below use `npx @protonspy/csdd`. Installed globally or from source? Drop the prefix and call `csdd` directly — or alias it: `alias csdd='npx @protonspy/csdd'`. - ---- - -## Quickstart — set up csdd in your project - -```bash -npx @protonspy/csdd init --with-baseline # bootstrap this repo into a csdd workspace (no install) -``` - -Then, **inside Claude Code**, adapt the workspace to *this* project's stack in one step: - -``` -/csdd-setup-init # inspect the project → tailor steering, a specialized implementer agent, and skills -/csdd-setup-update # later: re-inspect and apply targeted adjustments, preserving your edits -``` - -Take your first feature from idea to ready-to-implement: - -```bash -npx @protonspy/csdd spec init my-feature -npx @protonspy/csdd spec generate my-feature --artifact requirements # → validate → approve → design → tasks → implement -``` - -### Commands at a glance - -| Command | What it does | -|---|---| -| `npx @protonspy/csdd init [--with-baseline]` | bootstrap the workspace (steering · skills · agents · commands · hooks) | -| `npx @protonspy/csdd copy /` | cherry-pick one shipped artifact (skill · agent · rule · command · hook · template · steering) into the workspace — the complement of `init --exclude` | -| `/csdd-setup-init` · `/csdd-setup-update` | adapt / refresh the workflow for your stack — Claude Code slash commands | -| `npx @protonspy/csdd spec init · generate · validate · approve · status` | the requirements → design → tasks gated lifecycle | -| `npx @protonspy/csdd steering · skill · agent · mcp …` | author the 5 governed resources (`create · list · show · delete`, plus `validate` where applicable) | -| `npx @protonspy/csdd update [--dry-run]` | upgrade managed artifacts, preserving your edits (`.old` backups) | -| `npx @protonspy/csdd web` | read-only live dashboard — spec progress, task board, file viewer | -| `/csdd-commit` | Conventional-Commit the reviewed slice, written from the diff + active spec | - -> New to the flow? Read on for the *why*. In a hurry? The three blocks above are the whole loop. - ---- - -## The problem - -AI agents write code fast. Too fast to ship without a contract. - -- **Scope creep.** Without an explicit, testable requirement, the agent "interprets" — and every run interprets differently. There is no baseline to review against. -- **Zero traceability.** Code with no link to a requirement. Nobody knows *why* a function exists, or what breaks if it changes. -- **Human review too late.** The human only sees the result in the PR — when reverting is expensive. The decision should be visible *before* the code. - -The answer is **SDD — Spec-Driven Development**: a contract layer (requirements → design → tasks) with human approval at every gate. **csdd is the tool that makes that contract impossible to break by accident.** - ---- - -## What csdd is - -A **CLI + TUI** in a single Go binary — the only sanctioned author of the workflow's artifacts. You don't hand-edit frontmatter, `spec.json`, or task annotations — you generate from a template, edit the body, and validate. - -### 🖥️ CLI — for agents & automation - -Flag-driven, headless. Exposes **100% of the functionality** so Claude Code, Cursor, Codex, or CI can drive the binary without reading the source. - -```bash -npx @protonspy/csdd spec generate photo-albums --artifact requirements -``` - -### ⌨️ TUI — for humans - -Interactive interface (Bubble Tea). Running `csdd` with no arguments opens wizards and an artifact browser. Same operations, same rules. - -```bash -npx @protonspy/csdd # no args → interactive TUI -``` - -> 🔑 **Core principle:** both surfaces call the **same operation helpers**. A single source of truth — what a human does in the TUI, an agent does identically via the CLI. - -### 🌐 Web — live dashboard - -`csdd web` serves a **read-only** dashboard in your browser: spec progress (phase, -approvals, % of tasks done, validation status), navigable requirements / design / -tasks (Markdown + Mermaid Boundary Maps), a **live task board** that updates as -files change on disk, and a VS Code-style file viewer (Monaco) for the whole -workspace — specs, steering, skills, agents, MCP, hooks and commands. - -```bash -npx @protonspy/csdd web # serve the dashboard and print its URL -npx @protonspy/csdd web --port 8080 # custom port -npx @protonspy/csdd web --tunnel # expose it publicly via a tunnel (forces auth) -``` - -It is a *view*, not an author — the CLI stays the only thing that writes -artifacts. And it's still a single binary: the React/Vite/Monaco UI is built -ahead of time and embedded, so it runs **offline** with no extra runtime -dependency. Binds to `127.0.0.1` by default. - -> The prebuilt binaries (npm / releases) embed the dashboard. Building from -> source (`go install …` or `go build`)? Run `make web-build` first to embed the -> UI — otherwise `csdd web` serves a placeholder page (the API still works). - ---- - -## Upgrading safely — `csdd update` - -A new csdd version ships new and improved managed artifacts (rules, templates, the shipped skills/agents/commands/hooks, the guide). `csdd update` brings your workspace up to that version **without ever losing your setup**: - -```bash -npx @protonspy/csdd update --dry-run # preview exactly what would change -npx @protonspy/csdd update # apply it -``` - -It tracks what it wrote in `.claude/.csdd-manifest.json` (a content-hash baseline) and, for each csdd-managed file, decides: - -| On disk vs. shipped | What update does | -|---|---| -| missing | **adds** it (new in this version) | -| identical | leaves it (already current) | -| pristine but outdated | **refreshes** it in place — you never touched it | -| **edited by you** | writes the new version **and** keeps your copy as `-1.old` (then `-2.old`, …) | - -So nothing is silently overwritten: any file you customized is preserved as a numbered `.old` backup beside it for you (or an agent) to diff and fold in. `--force` overwrites in place without the backup. - -> 🔒 **Never touched by update:** your `specs/`, your filled `.claude/steering/*.md`, custom (non-shipped) skills/agents, `.mcp.json`, `.claude/settings.json`, and `CLAUDE.md`. Update reconciles only the pure-csdd artifacts. - ---- - -## The 5 resources csdd governs - -| Resource | What it is | Location | -|----------|-----------|----------| -| 🧭 **steering** | Project memory loaded into every agent interaction. Standards and the *why* behind decisions. | `.claude/steering/*.md` | -| 📐 **spec** | Per-feature contract: `spec.json` + requirements + design + tasks (+ research/bugfix). | `specs//` | -| 🛠️ **skill** | Executable workflow bundle: `SKILL.md` + references + assets + scripts. | `.claude/skills//` | -| 🤖 **agent** | Custom sub-agent with a **least-privilege** tool scope (reviewer, debugger…). | `.claude/agents/.md` | -| 🔌 **mcp** | Model Context Protocol servers the agent can connect to. stdio or remote, never both. | `.mcp.json` | - -**Verbs per resource.** Common base: `create/init · list · show · delete`. `spec` adds `generate · approve · validate · status`; `mcp` uses `add · remove · enable · disable · validate`; `skill` adds `add-reference/script/asset · validate`. - ---- - -## Mental model — read this first - -Two distinctions matter more than anything else. - -### 📜 Specification — the contract -The requirements, the File Structure Plan in the design, and the `_Boundary:_` / `_Depends:_` annotations on the tasks. 👤 **Humans review and approve this.** - -### 🧩 Design — the implementation space -Components, internals, sequencing *within* each task. How the contract is fulfilled. 🤖 **The agent is free here, after approval.** - -The second distinction is the **phase gates**: no phase is generated before the previous one is approved by a human — and that is **enforced mechanically**, not by convention. - ---- - -## Phase gates — the heart of the flow - -Four phases, three human gates: - -``` -Discovery → [gate] Requirements → [gate] Design → [gate] Tasks → Implementation -``` - -State lives in `spec.json`. Generating `design` while `requirements` is not approved **fails** — it's not a warning, it's exit code **2**. - -- `ready_for_implementation` only becomes `true` after all 3 approvals. -- `--force` breaks the gate — only with explicit human authorization (Quick Plan), and it shows up in history. - -```bash -npx @protonspy/csdd spec generate albums --artifact design -✗ phase gate: 'requirements' must be - approved before generating 'design'. - -# the right path: -npx @protonspy/csdd spec approve albums --phase requirements -✓ requirements approved -``` - ---- - -## Feature lifecycle — from idea to ready-to-implement - -```bash -# 1 · bootstrap (once per repo) -npx @protonspy/csdd init --with-baseline - -# 2 · create the feature workspace -npx @protonspy/csdd spec init photo-albums - -# 3 · requirements → edit in EARS → validate → approve -npx @protonspy/csdd spec generate photo-albums --artifact requirements -npx @protonspy/csdd spec validate photo-albums # exit 2 = fix what it flags -npx @protonspy/csdd spec approve photo-albums --phase requirements - -# 4 · design (blocked until step 3 passes) → 5 · tasks (same) -npx @protonspy/csdd spec generate photo-albums --artifact design # ... validate, approve -npx @protonspy/csdd spec generate photo-albums --artifact tasks # ... validate, approve - -✓ spec.json: ready_for_implementation = true # implementation can begin -``` - -> 💡 `npx @protonspy/csdd spec status ` between any two steps: phase + approvals + validation issues on a single screen. - ---- - -## Conventions the validator enforces - -### 📝 Requirements in EARS - -Fixed, testable syntax, one behavior per criterion. `SHALL` — never `should`. Unique `N.M` IDs. - -``` -### Requirement 1: Album Management -1. WHEN a user creates an album - THEN the system SHALL persist it <500ms. -2. IF the name is empty - THEN the system SHALL return 400. -3. WHILE deleting THE SYSTEM SHALL - block new uploads. -``` - -### ✅ Annotated tasks (not a todo-list) - -Each leaf traces requirements; parallelism is declared and verified. - -``` -- [ ] 2. AlbumService _Boundary: AlbumService_ - - [ ] 2.1 create / rename / delete - _Requirements: 1.1, 1.2_ -- [ ] 3. PhotoService _Boundary: PhotoService_ (P) - - [ ] 3.1 upload S3 - _Requirements: 2.1_ - _Depends: 1.2_ -``` - -- `_Requirements:_` on every leaf -- `_Boundary:_` on every `(P)` -- `_Depends:_` between boundaries - -`(P)` = runs in parallel. **Two `(P)` tasks cannot share a boundary** — the validator rejects it, guaranteeing safe parallel execution by agents. - ---- - -## Implementation phase — one task at a time, TDD - -Once the gate is open, the agent implements in **TDD**: - -``` -RED (write the test) → GREEN (minimum to pass) → REFACTOR (clean under green) → widen the net (full suite + lint) -``` - -Drive it with the shipped **`implementer` agent** — a language-agnostic sub-agent that takes one task, runs the `tdd-cycle`, stays inside its `design.md` boundary, runs the gate, records evidence, **marks the task done**, and reports. Specialize it per stack (e.g. a `go-developer`) via steering and skills — `/csdd-setup-init` derives one for your project. - -### 🔴 Skill `tdd-cycle` -- **One leaf task per invocation.** Takes the ID from `specs//tasks.md`; doesn't batch tasks "to save time". -- **RED fails for the right reason.** A compile error doesn't count — it cites the failure before moving on. -- **Never weakens a test** to make the suite pass. New behavior = new RED. -- **Marks the task done.** Once it's green, checks the task `[x]` in `tasks.md` — so progress (and the dashboard) reflect reality. - -### ✅ `verify-change` + Definition of Done -Before reporting done: run the executable checks and produce **real evidence** — "compiles" and "looks right" are not *done*. - -``` -go test ./... ✓ -lint ✓ -typecheck ✓ -build ✓ -``` - -Each leaf traces `_Requirements:_` → the test proves the requirement. **Evidence beats assertion.** - ---- - -## From done code to PR — fixed order, with evidence - -``` -code-reviewer → /csdd-commit → git push (pre-push gate) → Pull Request -``` - -### 🔎 Adversarial review — skill `pr-review` -- `code-reviewer` runs on the diff; resolve every **Blocker** before moving on. -- `security-reviewer` if it touches auth / secrets / input — resolve Critical/High. -- Reviewers **don't write** — you apply the fix and re-review until clean. - -### ✍️ `/csdd-commit` + pre-push gate - -``` -# Conventional Commits, generated from the diff + spec -feat(photo-albums): add album rename - -Implements photo-albums; tasks 2.1, 2.2. - -# git push → hook runs the suite; red BLOCKS -git push -✗ pre-push: test gate failed — push blocked -``` - -**Never** commit with an open Blocker; **never** `git push --no-verify`. The PR carries **evidence**: spec links · completed tasks · real check output · risks. - ---- - -## Workflows — upstream & downstream (BMAD-style) - -Everything above is the *downstream* contract: requirements → design → tasks → code. But where do the requirements come from, and who decides *what* to build? `csdd init` ships two orchestrator workflows — modeled on the [BMAD Method](https://github.com/bmadcode/BMAD-METHOD)'s phase-gated lifecycle — that bracket the SDD core. Each is an **agent** (the lead) fronting a set of phase **skills**, and the seam between them is a normal, validated csdd spec. - -``` -wf:product/discovery ──(handoff: steering + spec requirements)──▶ wf:development - UPSTREAM DOWNSTREAM - what & why, decision-ready how, built & verified -``` - -### 🧭 `wf-product-discovery` — *what & why* (upstream) - -Agent that drives a raw idea to a decision-ready PRD, one optional phase at a time, then lands it into csdd: - -| Skill | Produces | -|-------|----------| -| `discovery-product-brief` | `docs/product/product-brief.md` — vision, user, value, scope | -| `discovery-research` | `docs/product/research/*.md` — evidence-graded market / domain / technical findings | -| `discovery-prfaq` | `docs/product/prfaq.md` — Working-Backwards press release + hardest FAQ | -| `discovery-prd` | `docs/product/prd.md` — features with numbered, testable `FR-N` (+ validation checklist) | -| `discovery-ux-spec` | `docs/product/ux/DESIGN.md` + `EXPERIENCE.md` — structure + behavior, traced to journeys | -| `discovery-handoff` | **bridge:** updates steering, runs `csdd spec init`, translates each `FR-N` into EARS requirements that pass `csdd spec validate` | - -### 🏗️ `wf-development` — *how, built & verified* (downstream) - -Agent that takes an approved spec and drives it to shipped code — **reusing** the existing `tdd-cycle`, `verify-change`, `code-reviewer`/`security-reviewer`, `pr-review`, and the `csdd spec` gates rather than duplicating them. The `dev-*` skills add only the planning layer BMAD covers that csdd did not: - -| Skill | Produces | Feeds csdd gate | -|-------|----------|-----------------| -| `dev-architecture` | `architecture.md` + ADRs | fills & approves `design.md` | -| `dev-epics-stories` | `epics.md` + stories | fills & approves `tasks.md` | -| `dev-readiness-check` | PASS / CONCERNS / FAIL verdict | required before the first `tdd-cycle` | -| `dev-sprint` | `sprint-status.yaml` | live task tracking | -| `dev-retrospective` | `retrospective.md` | folds durable lessons into steering | - -> 🔑 The workflows never bypass the contract. Discovery output becomes a real EARS spec; architecture and stories become a real `design.md`/`tasks.md`. The phase gates stay mechanical — the workflows just decide *which* gate to open next. - ---- - -## Architecture — two surfaces, one core - -``` - cmd/csdd/main.go - (no args → TUI · with args → CLI) - │ - ┌────────────────────┴────────────────────┐ - internal/cli · CLI internal/tui · TUI - dispatcher, 1 file/resource Bubble Tea · wizards + browser - └──── both call the SAME operation helpers ────┘ - │ - workspace · paths · validator · templater · frontmatter · render - │ - artifacts on disk: .claude/ · specs/ · CLAUDE.md · .mcp.json - (plain text, reviewable in a PR) -``` - -| Package | Responsibility | Why it matters | -|---------|---------------|----------------| -| `internal/cli` | **CLI surface.** Dispatches `resource action`, flag parsing, 1 file per resource. Includes `CLAUDE.md` and `.gitignore` wiring. | The public contract — 100% of functionality, headless. | -| `internal/tui` | **Interactive front-end** (Bubble Tea): menu, wizards, artifact browser. | Calls the same helpers as `cli`. No duplicated logic. | -| `internal/workspace` | Resolves the `.claude/` root by walking up the tree; validates kebab-case; enumerates phases and artifacts. | Defines what a workspace *is* and the valid names. | -| `internal/paths` | Centralizes the on-disk layout: `.claude/`, `CLAUDE.md`, `.mcp.json`, `specs/`. | The layout lives in *exactly one* place. | -| `internal/validator` | **The mechanical checks:** EARS, unique IDs, traceability, annotations, parallelism safety, skill structure. | The agent's "friend." Never asks for judgment — only true/false. Exit 2. | -| `internal/templater` | Renders templates **embedded at compile time** (`go:embed`). | A fully self-contained binary — zero runtime dependencies. | -| `internal/frontmatter` | Parser for a minimal subset of YAML (scalars, bool, inline arrays). | Does only what's needed — small, predictable surface. | -| `internal/render` | Terminal output helpers with color (respects `NO_COLOR`/TTY). | Consistent `✓ ✗ ! •` messages in the CLI. | - ---- - -## Design principles — four deliberate choices - -1. **CLI = TUI, always.** Both surfaces converge on the same helpers. There is no function only the TUI can do — which is why a headless agent has 100% of the power. -2. **Embedded templates.** `go:embed all:templates` compiles the templates into the binary. You download one file and it works offline, with nothing to install. -3. **Mechanical, not opinionated, validation.** The validator never asks for judgment: either the criterion starts with `WHEN` or it doesn't. Deterministic → an agent can trust the exit code. -4. **Artifacts are plain text.** Everything becomes versionable markdown/JSON in `.claude/` and `specs/`. Review happens in the PR, with the tools the team already uses. - -> The result: **the CLI never stops you from doing the right thing** — it stops you from doing the wrong thing *without making the decision visible*. Breaking a gate requires an explicit `--force`, and that shows up in history. - ---- - -## What the validator catches - -| Gate | Checks | -|------|--------| -| **spec · requirements** | Every criterion starts with WHEN/WHILE/IF/WHERE/THE SYSTEM · none uses `should` · `### Requirement N:` headers unique | -| **spec · design** | Boundary Map and File Structure Plan sections present · every requirement ID appears in the traceability table · `design.md` ≤ 1000 lines (else split the spec) | -| **spec · tasks** | Every leaf has `_Requirements:_` with real IDs · every `(P)` has a `_Boundary:_` that matches the design · no `(P)` pair shares a boundary | -| **skill · mcp · steering** | `SKILL.md` ≤ 500 lines / ~5k tokens, refs cited · mcp: exactly 1 transport (stdio **or** url) · steering: valid `inclusion`, fileMatch has a pattern | - -**Exit codes:** `0` ok · `1` usage error · `2` validation failure. Scriptable in CI. - ---- - -## Integration — native to Claude Code, no conversion layer - -The workspace `csdd` writes **is** the layout Claude Code expects. `csdd init` bootstraps it and handles the wiring: - -``` -CLAUDE.md # entry point + steering imports -.claude/steering/*.md # @-referenced from CLAUDE.md -.claude/agents/*.md # sub-agents (implementer, code-reviewer, …) -.claude/skills// # skill bundles -.claude/commands/ # slash commands (/csdd-setup-init, /csdd-setup-update, /csdd-commit) -.claude/hooks/ # deterministic automation -specs// # SDD contracts -.mcp.json # MCP servers -``` - -Creating a steering automatically inserts `@.claude/steering/` into a managed block of `CLAUDE.md` — idempotent, never clobbering manual edits. - -**What the team gains:** -- **Zero friction with Claude Code.** Artifacts are read natively — no exporting or converting. -- **Review where we already work.** Specs and steering are text in a PR — diff, comment, approve. -- **Least privilege by default.** Sub-agents are born with `Read, Grep`; MCP with a restricted scope. -- **CI validates the contract.** A `csdd spec validate` in the pipeline blocks a broken spec before merge. - ---- - -## 🔌 MCP server — drive csdd as native tools - -Prefer your agent to call **tools** over shelling out to a terminal? -[`@protonspy/csdd-mcp`](mcp-server/) is an MCP server (stdio) that exposes the csdd -**development flow** as tools — `csdd_spec_generate`, `csdd_steering_create`, -`csdd_spec_approve`, … **27 in total.** It wraps the same CLI, so the contract is -intact: phase gates still block, the validator still runs, and **exit 2 surfaces -as a distinct "validation failed" result** the agent can branch on. Typed -parameters (enums for `artifact`/`phase`/`inclusion`) mean the agent picks valid -inputs and the server builds the argv — more precise than hand-written commands. - -`csdd init` registers the server in `.mcp.json` for you (pass `--no-mcp` to skip): - -```bash -# already wired by `csdd init`; to add it to an existing workspace: -claude mcp add csdd -- npx -y @protonspy/csdd-mcp -``` - -- **Dev-flow only**, grouped by resource (steering · spec · skill · agent), plus `csdd_version`. **Setup and config management stay on the CLI** — `init`, `mcp`, and `export` are one-time human operations, not agent-loop tools. -- **Same binary, same rules.** The server just builds the argv and runs `csdd` headlessly (`NO_COLOR`, no TTY) — no logic of its own, so the CLI stays the single source of truth. -- **Zero-config binary** via `npx` (the matching prebuilt `csdd` is an `optionalDependency`); override with `CSDD_BIN`. - -Full tool reference and configuration: [`mcp-server/README.md`](mcp-server/README.md). - ---- - -## Interop — export to Kiro / Codex - -`csdd` is Claude Code-native, but the SDD artifacts aren't locked in. `csdd export` -converts the workspace to other agentic toolchains — a one-way, **additive** export -that lives alongside `.claude/` (nothing is overwritten in place): - -```bash -npx @protonspy/csdd export kiro # → .kiro/steering/*.md + .kiro/specs//{requirements,design,tasks}.md -npx @protonspy/csdd export codex # → AGENTS.md (CLAUDE.md + steering inlined) + .codex/config.toml (MCP) -npx @protonspy/csdd export kiro --out ./build --force -``` - -- **Kiro** — steering frontmatter (`inclusion: always|fileMatch|manual|auto`, `fileMatchPattern`) is already Kiro-compatible, so steering copies verbatim; specs copy their SDD markdown (`spec.json` is dropped — Kiro tracks phase state in-IDE). -- **Codex** — Codex has no `@`-import, so the managed steering block in `CLAUDE.md` is replaced by the steering inlined into `AGENTS.md`; `.mcp.json` becomes `[mcp_servers.*]` tables in `.codex/config.toml`. - ---- - -## Getting started - -```bash -# bootstrap a repo with baseline steering -npx @protonspy/csdd init --with-baseline - -# take your first feature through to ready_for_implementation -npx @protonspy/csdd spec init my-feature -npx @protonspy/csdd spec generate my-feature --artifact requirements -``` - -**Takeaways:** The validator is your friend. The gate makes the decision *visible*. Contract before code — requirements → design → tasks, each approved by a human before the next. Always generate from a template; never hand-write frontmatter or `spec.json`. Least privilege everywhere. +# csdd + +**Claude Spec-Driven Development — as an executable contract.** + +`csdd` is a single Go binary that makes **Spec-Driven Development (SDD) + Test-Driven Development (TDD)** the native, *enforced* workflow inside a [Claude Code](https://claude.com/claude-code) repository. It turns the SDD lifecycle — `requirements → design → tasks → implementation` — from "good intentions in markdown" into a contract that is **validated mechanically and gated by human approval at every phase**, so neither a human nor an AI agent can skip a step, ship a requirement without a testable criterion, or mark a parallel task without declaring its boundary. + +It owns five artifact types — **steering** (project memory), **specs** (per-feature contracts), **skills** (executable workflow bundles), **agents** (scoped sub-agents), and **MCP servers** — and is their only sanctioned author, so their structure stays machine-checkable instead of drifting into free-form prose. There's no server and no database: the plain Markdown/JSON files under `.claude/` and `specs/` *are* the API, reviewable in any pull request. + +## Install & run + +**npx — zero install, always the latest.** Fetches the right prebuilt binary for your OS/arch: + +```bash +npx @protonspy/csdd --help # run instantly +npx @protonspy/csdd # no args → interactive TUI +``` + +**Global install** — prefer a short `csdd` on your `PATH`: + +```bash +npm install -g @protonspy/csdd +csdd --help +``` + +**Other ways in:** grab a prebuilt archive from the [releases page](https://github.com/protonspy/csdd/releases) and put `csdd` on your `PATH`, or build from source with `go install github.com/protonspy/csdd@latest`. + +> The examples below use `npx @protonspy/csdd`. Installed globally or from source? Drop the prefix and call `csdd` directly — or alias it: `alias csdd='npx @protonspy/csdd'`. + +--- + +## Quickstart — set up csdd in your project + +```bash +npx @protonspy/csdd init --with-baseline # bootstrap this repo into a csdd workspace (no install) +``` + +Then, **inside Claude Code**, adapt the workspace to *this* project's stack in one step: + +``` +/csdd-setup-init # inspect the project → tailor steering, a specialized implementer agent, and skills +/csdd-setup-update # later: re-inspect and apply targeted adjustments, preserving your edits +``` + +Take your first feature from idea to ready-to-implement: + +```bash +npx @protonspy/csdd spec init my-feature +npx @protonspy/csdd spec generate my-feature --artifact requirements # → validate → approve → design → tasks → implement +``` + +### Commands at a glance + +| Command | What it does | +|---|---| +| `npx @protonspy/csdd init [--with-baseline]` | bootstrap the workspace (steering · skills · agents · commands · hooks) | +| `npx @protonspy/csdd copy /` | cherry-pick one shipped artifact (skill · agent · rule · command · hook · template · steering) into the workspace — the complement of `init --exclude` | +| `/csdd-setup-init` · `/csdd-setup-update` | adapt / refresh the workflow for your stack — Claude Code slash commands | +| `npx @protonspy/csdd spec init · generate · validate · approve · status` | the requirements → design → tasks gated lifecycle | +| `npx @protonspy/csdd steering · skill · agent · mcp …` | author the 5 governed resources (`create · list · show · delete`, plus `validate` where applicable) | +| `npx @protonspy/csdd update [--dry-run]` | upgrade managed artifacts, preserving your edits (`.old` backups) | +| `npx @protonspy/csdd web` | read-only live dashboard — spec progress, task board, file viewer | +| `/csdd-commit` | Conventional-Commit the reviewed slice, written from the diff + active spec | + +> New to the flow? Read on for the *why*. In a hurry? The three blocks above are the whole loop. + +--- + +## The problem + +AI agents write code fast. Too fast to ship without a contract. + +- **Scope creep.** Without an explicit, testable requirement, the agent "interprets" — and every run interprets differently. There is no baseline to review against. +- **Zero traceability.** Code with no link to a requirement. Nobody knows *why* a function exists, or what breaks if it changes. +- **Human review too late.** The human only sees the result in the PR — when reverting is expensive. The decision should be visible *before* the code. + +The answer is **SDD — Spec-Driven Development**: a contract layer (requirements → design → tasks) with human approval at every gate. **csdd is the tool that makes that contract impossible to break by accident.** + +--- + +## What csdd is + +A **CLI + TUI** in a single Go binary — the only sanctioned author of the workflow's artifacts. You don't hand-edit frontmatter, `spec.json`, or task annotations — you generate from a template, edit the body, and validate. + +### 🖥️ CLI — for agents & automation + +Flag-driven, headless. Exposes **100% of the functionality** so Claude Code, Cursor, Codex, or CI can drive the binary without reading the source. + +```bash +npx @protonspy/csdd spec generate photo-albums --artifact requirements +``` + +### ⌨️ TUI — for humans + +Interactive interface (Bubble Tea). Running `csdd` with no arguments opens wizards and an artifact browser. Same operations, same rules. + +```bash +npx @protonspy/csdd # no args → interactive TUI +``` + +> 🔑 **Core principle:** both surfaces call the **same operation helpers**. A single source of truth — what a human does in the TUI, an agent does identically via the CLI. + +### 🌐 Web — live dashboard + +`csdd web` serves a **read-only** dashboard in your browser: spec progress (phase, +approvals, % of tasks done, validation status), navigable requirements / design / +tasks (Markdown + Mermaid Boundary Maps), a **live task board** that updates as +files change on disk, and a VS Code-style file viewer (Monaco) for the whole +workspace — specs, steering, skills, agents, MCP, hooks and commands. + +```bash +npx @protonspy/csdd web # serve the dashboard and print its URL +npx @protonspy/csdd web --port 8080 # custom port +npx @protonspy/csdd web --tunnel # expose it publicly via a tunnel (forces auth) +``` + +It is a *view*, not an author — the CLI stays the only thing that writes +artifacts. And it's still a single binary: the React/Vite/Monaco UI is built +ahead of time and embedded, so it runs **offline** with no extra runtime +dependency. Binds to `127.0.0.1` by default. + +> The prebuilt binaries (npm / releases) embed the dashboard. Building from +> source (`go install …` or `go build`)? Run `make web-build` first to embed the +> UI — otherwise `csdd web` serves a placeholder page (the API still works). + +--- + +## Upgrading safely — `csdd update` + +A new csdd version ships new and improved managed artifacts (rules, templates, the shipped skills/agents/commands/hooks, the guide). `csdd update` brings your workspace up to that version **without ever losing your setup**: + +```bash +npx @protonspy/csdd update --dry-run # preview exactly what would change +npx @protonspy/csdd update # apply it +``` + +It tracks what it wrote in `.claude/.csdd-manifest.json` (a content-hash baseline) and, for each csdd-managed file, decides: + +| On disk vs. shipped | What update does | +|---|---| +| missing | **adds** it (new in this version) | +| identical | leaves it (already current) | +| pristine but outdated | **refreshes** it in place — you never touched it | +| **edited by you** | writes the new version **and** keeps your copy as `-1.old` (then `-2.old`, …) | + +So nothing is silently overwritten: any file you customized is preserved as a numbered `.old` backup beside it for you (or an agent) to diff and fold in. `--force` overwrites in place without the backup. + +> 🔒 **Never touched by update:** your `specs/`, your filled `.claude/steering/*.md`, custom (non-shipped) skills/agents, `.mcp.json`, `.claude/settings.json`, and `CLAUDE.md`. Update reconciles only the pure-csdd artifacts. + +--- + +## The 5 resources csdd governs + +| Resource | What it is | Location | +|----------|-----------|----------| +| 🧭 **steering** | Project memory loaded into every agent interaction. Standards and the *why* behind decisions. | `.claude/steering/*.md` | +| 📐 **spec** | Per-feature contract: `spec.json` + requirements + design + tasks (+ research/bugfix). | `specs//` | +| 🛠️ **skill** | Executable workflow bundle: `SKILL.md` + references + assets + scripts. | `.claude/skills//` | +| 🤖 **agent** | Custom sub-agent with a **least-privilege** tool scope (reviewer, debugger…). | `.claude/agents/.md` | +| 🔌 **mcp** | Model Context Protocol servers the agent can connect to. stdio or remote, never both. | `.mcp.json` | + +**Verbs per resource.** Common base: `create/init · list · show · delete`. `spec` adds `generate · approve · validate · status`; `mcp` uses `add · remove · enable · disable · validate`; `skill` adds `add-reference/script/asset · validate`. + +--- + +## Mental model — read this first + +Two distinctions matter more than anything else. + +### 📜 Specification — the contract +The requirements, the File Structure Plan in the design, and the `_Boundary:_` / `_Depends:_` annotations on the tasks. 👤 **Humans review and approve this.** + +### 🧩 Design — the implementation space +Components, internals, sequencing *within* each task. How the contract is fulfilled. 🤖 **The agent is free here, after approval.** + +The second distinction is the **phase gates**: no phase is generated before the previous one is approved by a human — and that is **enforced mechanically**, not by convention. + +--- + +## Phase gates — the heart of the flow + +Four phases, three human gates: + +``` +Discovery → [gate] Requirements → [gate] Design → [gate] Tasks → Implementation +``` + +State lives in `spec.json`. Generating `design` while `requirements` is not approved **fails** — it's not a warning, it's exit code **2**. + +- `ready_for_implementation` only becomes `true` after all 3 approvals. +- `--force` breaks the gate — only with explicit human authorization (Quick Plan), and it shows up in history. + +```bash +npx @protonspy/csdd spec generate albums --artifact design +✗ phase gate: 'requirements' must be + approved before generating 'design'. + +# the right path: +npx @protonspy/csdd spec approve albums --phase requirements +✓ requirements approved +``` + +--- + +## Feature lifecycle — from idea to ready-to-implement + +```bash +# 1 · bootstrap (once per repo) +npx @protonspy/csdd init --with-baseline + +# 2 · create the feature workspace +npx @protonspy/csdd spec init photo-albums + +# 3 · requirements → edit in EARS → validate → approve +npx @protonspy/csdd spec generate photo-albums --artifact requirements +npx @protonspy/csdd spec validate photo-albums # exit 2 = fix what it flags +npx @protonspy/csdd spec approve photo-albums --phase requirements + +# 4 · design (blocked until step 3 passes) → 5 · tasks (same) +npx @protonspy/csdd spec generate photo-albums --artifact design # ... validate, approve +npx @protonspy/csdd spec generate photo-albums --artifact tasks # ... validate, approve + +✓ spec.json: ready_for_implementation = true # implementation can begin +``` + +> 💡 `npx @protonspy/csdd spec status ` between any two steps: phase + approvals + validation issues on a single screen. + +--- + +## Conventions the validator enforces + +### 📝 Requirements in EARS + +Fixed, testable syntax, one behavior per criterion. `SHALL` — never `should`. Unique `N.M` IDs. + +``` +### Requirement 1: Album Management +1. WHEN a user creates an album + THEN the system SHALL persist it <500ms. +2. IF the name is empty + THEN the system SHALL return 400. +3. WHILE deleting THE SYSTEM SHALL + block new uploads. +``` + +### ✅ Annotated tasks (not a todo-list) + +Each leaf traces requirements; parallelism is declared and verified. + +``` +- [ ] 2. AlbumService _Boundary: AlbumService_ + - [ ] 2.1 create / rename / delete + _Requirements: 1.1, 1.2_ +- [ ] 3. PhotoService _Boundary: PhotoService_ (P) + - [ ] 3.1 upload S3 + _Requirements: 2.1_ + _Depends: 1.2_ +``` + +- `_Requirements:_` on every leaf +- `_Boundary:_` on every `(P)` +- `_Depends:_` between boundaries + +`(P)` = runs in parallel. **Two `(P)` tasks cannot share a boundary** — the validator rejects it, guaranteeing safe parallel execution by agents. + +--- + +## Implementation phase — one task at a time, TDD + +Once the gate is open, the agent implements in **TDD**: + +``` +RED (write the test) → GREEN (minimum to pass) → REFACTOR (clean under green) → widen the net (full suite + lint) +``` + +Drive it with the shipped **`implementer` agent** — a language-agnostic sub-agent that takes one task, runs the `tdd-cycle`, stays inside its `design.md` boundary, runs the gate, records evidence, **marks the task done**, and reports. Specialize it per stack (e.g. a `go-developer`) via steering and skills — `/csdd-setup-init` derives one for your project. + +### 🔴 Skill `tdd-cycle` +- **One leaf task per invocation.** Takes the ID from `specs//tasks.md`; doesn't batch tasks "to save time". +- **RED fails for the right reason.** A compile error doesn't count — it cites the failure before moving on. +- **Never weakens a test** to make the suite pass. New behavior = new RED. +- **Marks the task done.** Once it's green, checks the task `[x]` in `tasks.md` — so progress (and the dashboard) reflect reality. + +### ✅ `verify-change` + Definition of Done +Before reporting done: run the executable checks and produce **real evidence** — "compiles" and "looks right" are not *done*. + +``` +go test ./... ✓ +lint ✓ +typecheck ✓ +build ✓ +``` + +Each leaf traces `_Requirements:_` → the test proves the requirement. **Evidence beats assertion.** + +--- + +## From done code to PR — fixed order, with evidence + +``` +code-reviewer → /csdd-commit → git push (pre-push gate) → Pull Request +``` + +### 🔎 Adversarial review — skill `pr-review` +- `code-reviewer` runs on the diff; resolve every **Blocker** before moving on. +- `security-reviewer` if it touches auth / secrets / input — resolve Critical/High. +- Reviewers **don't write** — you apply the fix and re-review until clean. + +### ✍️ `/csdd-commit` + pre-push gate + +``` +# Conventional Commits, generated from the diff + spec +feat(photo-albums): add album rename + +Implements photo-albums; tasks 2.1, 2.2. + +# git push → hook runs the suite; red BLOCKS +git push +✗ pre-push: test gate failed — push blocked +``` + +**Never** commit with an open Blocker; **never** `git push --no-verify`. The PR carries **evidence**: spec links · completed tasks · real check output · risks. + +--- + +## Workflows — upstream & downstream (BMAD-style) + +Everything above is the *downstream* contract: requirements → design → tasks → code. But where do the requirements come from, and who decides *what* to build? `csdd init` ships two orchestrator workflows — modeled on the [BMAD Method](https://github.com/bmadcode/BMAD-METHOD)'s phase-gated lifecycle — that bracket the SDD core. Each is an **agent** (the lead) fronting a set of phase **skills**, and the seam between them is a normal, validated csdd spec. + +``` +wf:product/discovery ──(handoff: steering + spec requirements)──▶ wf:development + UPSTREAM DOWNSTREAM + what & why, decision-ready how, built & verified +``` + +### 🧭 `wf-product-discovery` — *what & why* (upstream) + +Agent that drives a raw idea to a decision-ready PRD, one optional phase at a time, then lands it into csdd: + +| Skill | Produces | +|-------|----------| +| `discovery-product-brief` | `docs/product/product-brief.md` — vision, user, value, scope | +| `discovery-research` | `docs/product/research/*.md` — evidence-graded market / domain / technical findings | +| `discovery-prfaq` | `docs/product/prfaq.md` — Working-Backwards press release + hardest FAQ | +| `discovery-prd` | `docs/product/prd.md` — features with numbered, testable `FR-N` (+ validation checklist) | +| `discovery-ux-spec` | `docs/product/ux/DESIGN.md` + `EXPERIENCE.md` — structure + behavior, traced to journeys | +| `discovery-handoff` | **bridge:** updates steering, runs `csdd spec init`, translates each `FR-N` into EARS requirements that pass `csdd spec validate` | + +### 🏗️ `wf-development` — *how, built & verified* (downstream) + +Agent that takes an approved spec and drives it to shipped code — **reusing** the existing `tdd-cycle`, `verify-change`, `code-reviewer`/`security-reviewer`, `pr-review`, and the `csdd spec` gates rather than duplicating them. The `dev-*` skills add only the planning layer BMAD covers that csdd did not: + +| Skill | Produces | Feeds csdd gate | +|-------|----------|-----------------| +| `dev-architecture` | `architecture.md` + ADRs | fills & approves `design.md` | +| `dev-epics-stories` | `epics.md` + stories | fills & approves `tasks.md` | +| `dev-readiness-check` | PASS / CONCERNS / FAIL verdict | required before the first `tdd-cycle` | +| `dev-sprint` | `sprint-status.yaml` | live task tracking | +| `dev-retrospective` | `retrospective.md` | folds durable lessons into steering | + +> 🔑 The workflows never bypass the contract. Discovery output becomes a real EARS spec; architecture and stories become a real `design.md`/`tasks.md`. The phase gates stay mechanical — the workflows just decide *which* gate to open next. + +--- + +## Architecture — two surfaces, one core + +``` + cmd/csdd/main.go + (no args → TUI · with args → CLI) + │ + ┌────────────────────┴────────────────────┐ + internal/cli · CLI internal/tui · TUI + dispatcher, 1 file/resource Bubble Tea · wizards + browser + └──── both call the SAME operation helpers ────┘ + │ + workspace · paths · validator · templater · frontmatter · render + │ + artifacts on disk: .claude/ · specs/ · CLAUDE.md · .mcp.json + (plain text, reviewable in a PR) +``` + +| Package | Responsibility | Why it matters | +|---------|---------------|----------------| +| `internal/cli` | **CLI surface.** Dispatches `resource action`, flag parsing, 1 file per resource. Includes `CLAUDE.md` and `.gitignore` wiring. | The public contract — 100% of functionality, headless. | +| `internal/tui` | **Interactive front-end** (Bubble Tea): menu, wizards, artifact browser. | Calls the same helpers as `cli`. No duplicated logic. | +| `internal/workspace` | Resolves the `.claude/` root by walking up the tree; validates kebab-case; enumerates phases and artifacts. | Defines what a workspace *is* and the valid names. | +| `internal/paths` | Centralizes the on-disk layout: `.claude/`, `CLAUDE.md`, `.mcp.json`, `specs/`. | The layout lives in *exactly one* place. | +| `internal/validator` | **The mechanical checks:** EARS, unique IDs, traceability, annotations, parallelism safety, skill structure. | The agent's "friend." Never asks for judgment — only true/false. Exit 2. | +| `internal/templater` | Renders templates **embedded at compile time** (`go:embed`). | A fully self-contained binary — zero runtime dependencies. | +| `internal/frontmatter` | Parser for a minimal subset of YAML (scalars, bool, inline arrays). | Does only what's needed — small, predictable surface. | +| `internal/render` | Terminal output helpers with color (respects `NO_COLOR`/TTY). | Consistent `✓ ✗ ! •` messages in the CLI. | + +--- + +## Design principles — four deliberate choices + +1. **CLI = TUI, always.** Both surfaces converge on the same helpers. There is no function only the TUI can do — which is why a headless agent has 100% of the power. +2. **Embedded templates.** `go:embed all:templates` compiles the templates into the binary. You download one file and it works offline, with nothing to install. +3. **Mechanical, not opinionated, validation.** The validator never asks for judgment: either the criterion starts with `WHEN` or it doesn't. Deterministic → an agent can trust the exit code. +4. **Artifacts are plain text.** Everything becomes versionable markdown/JSON in `.claude/` and `specs/`. Review happens in the PR, with the tools the team already uses. + +> The result: **the CLI never stops you from doing the right thing** — it stops you from doing the wrong thing *without making the decision visible*. Breaking a gate requires an explicit `--force`, and that shows up in history. + +--- + +## What the validator catches + +| Gate | Checks | +|------|--------| +| **spec · requirements** | Every criterion starts with WHEN/WHILE/IF/WHERE/THE SYSTEM · none uses `should` · `### Requirement N:` headers unique | +| **spec · design** | Boundary Map and File Structure Plan sections present · every requirement ID appears in the traceability table · `design.md` ≤ 1000 lines (else split the spec) | +| **spec · tasks** | Every leaf has `_Requirements:_` with real IDs · every `(P)` has a `_Boundary:_` that matches the design · no `(P)` pair shares a boundary | +| **skill · mcp · steering** | `SKILL.md` ≤ 500 lines / ~5k tokens, refs cited · mcp: exactly 1 transport (stdio **or** url) · steering: valid `inclusion`, fileMatch has a pattern | + +**Exit codes:** `0` ok · `1` usage error · `2` validation failure. Scriptable in CI. + +--- + +## Integration — native to Claude Code, no conversion layer + +The workspace `csdd` writes **is** the layout Claude Code expects. `csdd init` bootstraps it and handles the wiring: + +``` +CLAUDE.md # entry point + steering imports +.claude/steering/*.md # @-referenced from CLAUDE.md +.claude/agents/*.md # sub-agents (implementer, code-reviewer, …) +.claude/skills// # skill bundles +.claude/commands/ # slash commands (/csdd-setup-init, /csdd-setup-update, /csdd-commit) +.claude/hooks/ # deterministic automation +specs// # SDD contracts +.mcp.json # MCP servers +``` + +Creating a steering automatically inserts `@.claude/steering/` into a managed block of `CLAUDE.md` — idempotent, never clobbering manual edits. + +**What the team gains:** +- **Zero friction with Claude Code.** Artifacts are read natively — no exporting or converting. +- **Review where we already work.** Specs and steering are text in a PR — diff, comment, approve. +- **Least privilege by default.** Sub-agents are born with `Read, Grep`; MCP with a restricted scope. +- **CI validates the contract.** A `csdd spec validate` in the pipeline blocks a broken spec before merge. + +--- + +## 🔌 MCP server — drive csdd as native tools + +Prefer your agent to call **tools** over shelling out to a terminal? +[`@protonspy/csdd-mcp`](mcp-server/) is an MCP server (stdio) that exposes the csdd +**development flow** as tools — `csdd_spec_generate`, `csdd_steering_create`, +`csdd_spec_approve`, … **27 in total.** It wraps the same CLI, so the contract is +intact: phase gates still block, the validator still runs, and **exit 2 surfaces +as a distinct "validation failed" result** the agent can branch on. Typed +parameters (enums for `artifact`/`phase`/`inclusion`) mean the agent picks valid +inputs and the server builds the argv — more precise than hand-written commands. + +`csdd init` registers the server in `.mcp.json` for you (pass `--no-mcp` to skip): + +```bash +# already wired by `csdd init`; to add it to an existing workspace: +claude mcp add csdd -- npx -y @protonspy/csdd-mcp +``` + +- **Dev-flow only**, grouped by resource (steering · spec · skill · agent), plus `csdd_version`. **Setup and config management stay on the CLI** — `init`, `mcp`, and `export` are one-time human operations, not agent-loop tools. +- **Same binary, same rules.** The server just builds the argv and runs `csdd` headlessly (`NO_COLOR`, no TTY) — no logic of its own, so the CLI stays the single source of truth. +- **Zero-config binary** via `npx` (the matching prebuilt `csdd` is an `optionalDependency`); override with `CSDD_BIN`. + +Full tool reference and configuration: [`mcp-server/README.md`](mcp-server/README.md). + +--- + +## Interop — export to Kiro / Codex + +`csdd` is Claude Code-native, but the SDD artifacts aren't locked in. `csdd export` +converts the workspace to other agentic toolchains — a one-way, **additive** export +that lives alongside `.claude/` (nothing is overwritten in place): + +```bash +npx @protonspy/csdd export kiro # → .kiro/steering/*.md + .kiro/specs//{requirements,design,tasks}.md +npx @protonspy/csdd export codex # → AGENTS.md (CLAUDE.md + steering inlined) + .codex/config.toml (MCP) +npx @protonspy/csdd export kiro --out ./build --force +``` + +- **Kiro** — steering frontmatter (`inclusion: always|fileMatch|manual|auto`, `fileMatchPattern`) is already Kiro-compatible, so steering copies verbatim; specs copy their SDD markdown (`spec.json` is dropped — Kiro tracks phase state in-IDE). +- **Codex** — Codex has no `@`-import, so the managed steering block in `CLAUDE.md` is replaced by the steering inlined into `AGENTS.md`; `.mcp.json` becomes `[mcp_servers.*]` tables in `.codex/config.toml`. + +--- + +## Getting started + +```bash +# bootstrap a repo with baseline steering +npx @protonspy/csdd init --with-baseline + +# take your first feature through to ready_for_implementation +npx @protonspy/csdd spec init my-feature +npx @protonspy/csdd spec generate my-feature --artifact requirements +``` + +**Takeaways:** The validator is your friend. The gate makes the decision *visible*. Contract before code — requirements → design → tasks, each approved by a human before the next. Always generate from a template; never hand-write frontmatter or `spec.json`. Least privilege everywhere. diff --git a/cmd/csdd/main.go b/cmd/csdd/main.go index aa9476b..1035dcc 100644 --- a/cmd/csdd/main.go +++ b/cmd/csdd/main.go @@ -20,7 +20,7 @@ func main() { if len(args) == 0 || args[0] == "tui" { // TUI gets the same embedded templates so its wizards can render artifacts. if err := tui.Run(templater.FS); err != nil { - os.Stderr.WriteString(err.Error() + "\n") + _, _ = os.Stderr.WriteString(err.Error() + "\n") os.Exit(1) } return diff --git a/csdd.html b/csdd.html deleted file mode 100644 index 624dc55..0000000 --- a/csdd.html +++ /dev/null @@ -1,620 +0,0 @@ - - - - - -csdd — Spec-Driven Development as an executable contract - - - -
-
- - -
-
Tech Talk · Development Team
- -

Claude Spec-Driven Development as an executable contract

-

A single binary that turns the SDD workflow for Claude Code from "good intentions in markdown" - into a mechanically validated contract — for humans and AI agents.

-
-
5
managed resources
-
1
binary, zero runtime deps
-
~8.7k
lines of Go
-
-
- - -
-
The problem
-

AI agents write code fast.
Too fast to ship without a contract.

-
-
🎯

Scope that slips

-

Without an explicit, testable requirement, the agent "interprets" — and every run interprets differently. There is no baseline to review against.

-
🔗

Zero traceability

-

Code with no link to a requirement. Nobody knows why a function exists, or what breaks if it changes.

-
⚖️

Human review too late

-

The human only sees the result in the PR — when reverting is expensive. The decision should be visible before the code.

-
-

The answer is SDD — Spec-Driven Development: a contract layer - (requirements → design → tasks) with human approval at every gate. - csdd is the tool that makes that contract impossible to break by accident.

-
- - -
-
What csdd is
-

CLI + TUI in a single Go binary

-

The only sanctioned author of the workflow's artifacts. You don't hand-edit frontmatter, - spec.json or task annotations — you generate from a template, edit the body, and validate.

-
-
-

🖥️ CLI — for agents & automation

-

Flag-driven, headless. Exposes 100% of the functionality - so Claude Code, Cursor, Codex or CI can drive the binary without reading the source.

-
csdd spec generate photo-albums --artifact requirements
-
-
-

⌨️ TUI — for humans

-

Interactive interface (Bubble Tea). csdd with no arguments - opens wizards and an artifact browser. Same operations, same rules.

-
csdd   # no args → interactive TUI
-
-
-

🔑 Core principle: both surfaces call the same operation helpers. - A single source of truth — what a human does in the TUI, an agent does identically via the CLI.

-
- - -
-
The 5 resources csdd governs
-

Everything an agent needs to work safely

-
-
🧭

steering

-

Project memory loaded into every agent interaction. Standards and the why behind decisions.

- .claude/steering/*.md
-
📐

spec

-

Per-feature contract: spec.json + requirements + design + tasks (+ research/bugfix).

- specs/<feature>/
-
🛠️

skill

-

Executable workflow bundle: SKILL.md + references + assets + scripts.

- .claude/skills/<name>/
-
🤖

agent

-

Custom sub-agent with a least-privilege tool scope (reviewer, debugger…).

- .claude/agents/<name>.md
-
🔌

mcp

-

Model Context Protocol servers the agent can connect to. stdio or remote, never both.

- .mcp.json
-
- ⌨️

verbs per resource

-

Common base create/init · list · show · delete. spec adds generate · approve · validate · status; mcp uses add · remove · enable · disable · validate; skill adds add-reference/script/asset · validate.

-
-
- - -
-
Mental model · read this first
-

Two distinctions matter more than anything

-
-
-

📜 Specification — the contract

-

The requirements, the File Structure Plan in the design, and the - _Boundary:_ / _Depends:_ annotations on the tasks.

-

👤 Humans review and approve this.

-
-
-

🧩 Design — the implementation space

-

Components, internals, sequencing within each task. - How the contract is fulfilled.

-

🤖 The agent is free here, after approval.

-
-
-

The second distinction is the phase gates: no phase is - generated before the previous one is approved by a human — and that is enforced mechanically, not by convention.

-
- - -
-
Phase gates · the heart of the flow
-

Four phases, three human gates

-
-
phase 0
Discovery
-
-
human gate
generate
Requirements
-
-
human gate
generate
Design
-
-
human gate
generate
Tasks
-
-
unlocks
Implementation
-
-
-
-

State lives in spec.json. Generating design while - requirements is not approved fails — it's not a warning, it's exit code 2.

-
    -
  • ready_for_implementation only becomes true after all 3 approvals.
  • -
  • --force breaks the gate — only with explicit human authorization (Quick Plan).
  • -
-
-
csdd spec generate albums --artifact design
-✗ phase gate: 'requirements' must be
-  approved before generating 'design'.
-
-# the right path:
-csdd spec approve albums --phase requirements
-✓ requirements approved
-
-
- - -
-
A feature's lifecycle
-

From idea to ready to implement

-
# 1 · bootstrap (once per repo)
-csdd init --with-baseline
-
-# 2 · create the feature workspace
-csdd spec init photo-albums
-
-# 3 · requirements → edit in EARS → validate → approve
-csdd spec generate photo-albums --artifact requirements
-csdd spec validate photo-albums          # exit 2 = fix what it flags
-csdd spec approve  photo-albums --phase requirements
-
-# 4 · design (blocked until step 3 passes)  → 5 · tasks (same)
-csdd spec generate photo-albums --artifact design   # ... validate, approve
-csdd spec generate photo-albums --artifact tasks    # ... validate, approve
-
-✓ spec.json: ready_for_implementation = true   # implementation can begin
-

💡 csdd spec status <feature> between any two steps: phase + approvals + validation issues on a single screen.

-
- - -
-
The conventions the validator enforces
-

EARS & task annotations

-
-
-

📝 Requirements in EARS

-

Fixed, testable syntax, one behavior per criterion. - SHALL — never should. Unique N.M IDs.

-
### Requirement 1: Album Management
-1. WHEN a user creates an album
-   THEN the system SHALL persist it <500ms.
-2. IF the name is empty
-   THEN the system SHALL return 400.
-3. WHILE deleting THE SYSTEM SHALL
-   block new uploads.
-
-
-

✅ Annotated tasks (not a todo-list)

-

Each leaf traces requirements; parallelism is declared and verified.

-
- [ ] 2. AlbumService _Boundary: AlbumService_
-  - [ ] 2.1 create / rename / delete
-    _Requirements: 1.1, 1.2_
-- [ ] 3. PhotoService _Boundary: PhotoService_ (P)
-  - [ ] 3.1 upload S3
-    _Requirements: 2.1_
-    _Depends: 1.2_
-
- _Requirements:_ every leaf - _Boundary:_ every (P) - _Depends:_ across boundaries -
-
-
-

(P) = runs in parallel. Two (P) tasks cannot - share a boundary — the validator rejects it, guaranteeing safe parallel execution by agents.

-
- - -
-
Implementation phase · one task at a time
-

Once the gate is open, the agent implements in TDD

-
-
test first
1 · write the test
RED
-
-
2 · minimum to pass
GREEN
-
-
3 · clean under green
REFACTOR
-
-
4 · suite + lint
widen the net
-
-
-
-

🔴 Skill tdd-cycle

-
    -
  • One leaf task per invocation. Takes the ID from specs/<f>/tasks.md; doesn't batch tasks "to save time".
  • -
  • RED fails for the right reason. A compile error doesn't count — it cites the failure before moving on.
  • -
  • Never weakens a test to make the suite pass. New behavior = new RED.
  • -
-
-
-

verify-change + Definition of Done

-

Before reporting done: run the executable checks and produce real evidence — "compiles" and "looks right" are not done.

-
# evidence pasted into the report / PR
-go test ./...   
-lint            
-typecheck       
-build           
-
-
-

Each leaf traces _Requirements:_ → the test proves the requirement. - Evidence beats assertion.

-
- - -
-
From done code to PR · fixed order, with evidence
-

review → commit → push → PR

-
-
read-only sub-agents
1 · adversarial
code-reviewer
-
-
2 · only after clean
/csdd-commit
-
-
pre-push gate
3 · tests run here
git push
-
-
4 · evidence
Pull Request
-
-
-
-

🔎 Adversarial review — skill pr-review

-
    -
  • code-reviewer runs on the diff; resolve every Blocker before moving on.
  • -
  • security-reviewer if it touches auth / secrets / input — resolve Critical/High.
  • -
  • Reviewers don't write — you apply the fix and re-review until clean.
  • -
-
-
-

✍️ /csdd-commit + pre-push gate

-
# Conventional Commits, generated from diff + spec
-feat(photo-albums): add album rename
-
-Implements photo-albums; tasks 2.1, 2.2.
-
-# git push → hook runs the suite; red BLOCKS
-git push
-✗ pre-push: test gate failed — push blocked
-
-
-

Never commit with an open Blocker; never git push --no-verify. - The PR carries evidence: spec links · completed tasks · real check output · risks.

-
- - -
-
Architecture · flow view
-

Two surfaces, one core

-
-
main.go
no args → TUI · with args → CLI
-
-
-
cmd/ · CLI
dispatcher + 1 file per resource
-
tui/ · TUI
Bubble Tea · wizards + browser
-
-
└──── both call the SAME operation helpers ────┘
-
-
-
workspace
resolves .claude/ · kebab-case
-
paths
layout in one place
-
validator
mechanical checks
-
templater
go:embed templates
-
frontmatter
minimal YAML parser
-
render
terminal output
-
-
-
artifacts on disk
.claude/ · specs/ · CLAUDE.md · .mcp.json — plain text, reviewable in a PR
-
-
- - -
-
Architecture · each package's role
-

Where each responsibility lives

- - - - - - - - - - - - -
PackageResponsibilityWhy it matters
cmd/CLI surface. Dispatches resource action, flag parsing, 1 file per resource. Includes CLAUDE.md and .gitignore wiring.The public contract — 100% of functionality, headless.
tui/Interactive front-end Bubble Tea: menu, wizards, artifact browser.Calls the same helpers as cmd. No duplicated logic.
internal/workspaceResolves the .claude/ root by walking up the tree; validates kebab-case; enumerates phases and artifacts.Defines what a workspace is and the valid names.
internal/pathsCentralizes the on-disk layout: .claude/, CLAUDE.md, .mcp.json, specs/.The layout lives in exactly one place.
internal/validatorThe mechanical checks: EARS, unique IDs, traceability, annotations, parallelism safety, skill structure.The agent's "friend." Never asks for judgment — only true/false. Exit 2.
internal/templaterRenders templates embedded at compile time (go:embed).A fully self-contained binary — zero runtime dependencies.
internal/frontmatterParser for a minimal subset of YAML (scalars, bool, inline arrays).Does only what's needed — small, predictable surface.
internal/renderTerminal output helpers with color (respects NO_COLOR/TTY).Consistent ✓ ✗ ! • messages in the CLI.
-
- - -
-
Architecture decisions worth highlighting
-

Four deliberate choices

-
-

① CLI = TUI, always

-

Both surfaces converge on the same helpers. There is no function only the TUI can do — which is why a headless agent has 100% of the power.

-

② Embedded templates

-

go:embed all:templates compiles the templates into the binary. You download one file and it works offline, with nothing to install.

-

③ Mechanical, not opinionated, validation

-

The validator never asks for judgment: either the criterion starts with WHEN or it doesn't. Deterministic → an agent trusts the exit code.

-

④ Artifacts are plain text

-

Everything becomes versionable markdown/JSON in .claude/ and specs/. Review happens in the PR, with the tools the team already uses.

-
-

The result: the CLI never stops you from doing the right thing — it stops you from doing - the wrong thing without making the decision visible. Breaking a gate requires an explicit --force, and that shows up in history.

-
- - -
-
What each validation gate catches
-

The validator is the safety net

-
-

spec · requirements

-
    -
  • Every criterion starts with WHEN/WHILE/IF/WHERE/THE SYSTEM
  • -
  • None uses should
  • -
  • Unique ### Requirement N: headers
  • -
-

spec · design

-
    -
  • Boundary Map and File Structure Plan sections present
  • -
  • Every requirement ID appears in the traceability table
  • -
  • design.md ≤ 1000 lines (else, split the spec)
  • -
-

spec · tasks

-
    -
  • Every leaf has _Requirements:_ with real IDs
  • -
  • Every (P) has a _Boundary:_ that matches the design
  • -
  • No (P) pair shares a boundary
  • -
-

skill · mcp · steering

-
    -
  • SKILL.md ≤ 500 lines / ~5k tokens; refs cited
  • -
  • mcp: exactly 1 transport (stdio or url)
  • -
  • steering: valid inclusion; fileMatch has a pattern
  • -
-
-

Exit codes: 0 ok · 1 usage error · 2 validation failure. Scriptable in CI.

-
- - -
-
How this reaches our day-to-day
-

Native to Claude Code — no conversion layer

-
-
-

The workspace csdd writes is the layout Claude Code expects. - csdd init bootstraps it and handles the wiring:

-
# native layout, written straight to disk
-CLAUDE.md             # entry point + steering imports
-.claude/steering/*.md # @-referenced from CLAUDE.md
-.claude/agents/*.md   # sub-agents (Read, Grep…)
-.claude/skills/<n>/   # skill bundles
-.claude/commands/     # slash commands (/csdd-commit)
-.claude/hooks/        # deterministic automation
-specs/<feature>/      # SDD contracts
-.mcp.json             # MCP servers
-

Creating a steering automatically inserts - @.claude/steering/<name> into a managed block of CLAUDE.md — idempotent, never clobbering manual edits.

-
-
-

What the team gains

-
    -
  • Zero friction with Claude Code. Artifacts are read natively — no exporting or converting.
  • -
  • Review where we already work. Specs and steering are text in a PR — diff, comment, approve.
  • -
  • Least privilege by default. Sub-agents are born with Read, Grep; MCP with a restricted scope.
  • -
  • CI validates the contract. A csdd spec validate in the pipeline blocks a broken spec before merge.
  • -
-
-
-
- - -
-
Takeaways
-

The validator is your friend.
The gate makes the decision visible.

-
-
📐

Contract before code

-

requirements → design → tasks, each approved by a human before the next.

-
⚙️

Always generate from a template

-

Never hand-write frontmatter or spec.json. Generate, edit the body, validate.

-
🛡️

Least privilege

-

Agents and MCP with the minimum scope. The more power, the smaller the scope and the more review.

-
-

Suggested next step: run csdd init --with-baseline on a real feature - and take the first spec all the way to ready_for_implementation together.

-
Questions? · · thank you 🙌
-
- -
- -
- -
- -
- - - - diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 58bb31d..3419cc9 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -21,6 +21,10 @@ func runAgent(args []string, templates embed.FS) int { render.Err(err.Error()) return 1 } + if isHelpFlag(action) { + help(os.Stdout) + return 0 + } switch action { case "create": return agentCreate(rest, templates) @@ -46,6 +50,7 @@ type AgentCreateOptions struct { Tools []string Model string Effort string // optional; empty = omit (inherit session config) + Color string // optional; empty = omit (no display color) Title string Force bool } @@ -57,8 +62,9 @@ func agentCreate(args []string, templates embed.FS) int { addRoot(fs, &opts.Root) fs.StringVar(&opts.Description, "description", "", "When the orchestrator should pick this agent.") fs.Var(&tools, "tools", "Tool name (repeatable). Default: Read, Grep.") - fs.StringVar(&opts.Model, "model", "", "Optional model override (e.g., sonnet, opus, haiku).") + fs.StringVar(&opts.Model, "model", "", "Optional model override (e.g., sonnet, opus, haiku, fable, or a full model ID).") fs.StringVar(&opts.Effort, "effort", "", "Optional effort: low|medium|high|xhigh|max.") + fs.StringVar(&opts.Color, "color", "", "Optional display color: red|blue|green|yellow|purple|orange|pink|cyan.") fs.StringVar(&opts.Title, "title", "", "Document title (default: derived from name).") addForce(fs, &opts.Force) positionals, err := parseFlags(fs, args) @@ -86,10 +92,13 @@ func AgentCreate(templates embed.FS, opts AgentCreateOptions) error { if err := workspace.KebabCheck(opts.Name, "agent"); err != nil { return err } - // Validate effort before writing any file, so an invalid value creates nothing. + // Validate effort and color before writing any file, so an invalid value creates nothing. if err := validateEffort(opts.Effort); err != nil { return err } + if err := validateColor(opts.Color); err != nil { + return err + } r, err := workspace.Resolve(opts.Root) if err != nil { return err @@ -114,6 +123,7 @@ func AgentCreate(templates embed.FS, opts AgentCreateOptions) error { "Tools": toolsStr, "Model": opts.Model, "Effort": opts.Effort, + "Color": opts.Color, "Title": title, }) if err != nil { @@ -166,6 +176,9 @@ func agentList(args []string) int { } func findAgent(root, name string) (string, bool) { + if err := workspace.SafeName(name, "agent"); err != nil { + return "", false + } base := workspace.AgentRoot(root) candidate := filepath.Join(base, name+".md") if info, err := os.Stat(candidate); err == nil && !info.IsDir() { diff --git a/internal/cli/agent_color_test.go b/internal/cli/agent_color_test.go new file mode 100644 index 0000000..8edf080 --- /dev/null +++ b/internal/cli/agent_color_test.go @@ -0,0 +1,42 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAgentCreateColor(t *testing.T) { + dir := freshWorkspace(t) + code, _, errOut := run(t, "agent", "create", "color-agent", + "--description", "d", "--color", "purple", "--root", dir) + if code != 0 { + t.Fatalf("agent create with color failed (code=%d): %s", code, errOut) + } + body := readAgentMD(t, dir, "color-agent") + if !strings.Contains(body, "color: purple") { + t.Errorf("agent missing color line:\n%s", body) + } +} + +func TestAgentCreateNoColorNoLine(t *testing.T) { + dir := freshWorkspace(t) + _, _, _ = run(t, "agent", "create", "plain-color-agent", "--description", "d", "--root", dir) + body := readAgentMD(t, dir, "plain-color-agent") + if strings.Contains(body, "color:") { + t.Errorf("agent without --color must not carry a color line:\n%s", body) + } +} + +func TestAgentCreateInvalidColorRejected(t *testing.T) { + dir := freshWorkspace(t) + code, _, _ := run(t, "agent", "create", "bad-color-agent", + "--description", "d", "--color", "teal", "--root", dir) + if code != 1 { + t.Errorf("invalid --color should exit 1, got %d", code) + } + if _, err := os.Stat(filepath.Join(dir, ".claude/agents/bad-color-agent.md")); !os.IsNotExist(err) { + t.Errorf("invalid --color must not create the agent file (err=%v)", err) + } +} diff --git a/internal/cli/claudemd.go b/internal/cli/claudemd.go index f010816..910f506 100644 --- a/internal/cli/claudemd.go +++ b/internal/cli/claudemd.go @@ -5,6 +5,8 @@ import ( "strings" "github.com/protonspy/csdd/internal/paths" + "github.com/protonspy/csdd/internal/textutil" + "github.com/protonspy/csdd/internal/workspace" ) // Markers delimiting the csdd-managed steering import block inside CLAUDE.md. @@ -24,7 +26,10 @@ func ensureSteeringImports(root string, names ...string) (int, error) { if err != nil { return 0, nil // no CLAUDE.md yet — nothing to wire } - text := string(data) + // Normalize line endings before matching: on a CRLF CLAUDE.md the existing + // import line is "imp\r\n", which neither dedup check below would match, so a + // duplicate @-import was appended on every run. + text := textutil.NormalizeNewlines(string(data)) start := strings.Index(text, steeringMarkerStart) end := strings.Index(text, steeringMarkerEnd) if start == -1 || end == -1 || end < start { @@ -52,7 +57,7 @@ func ensureSteeringImports(root string, names ...string) (int, error) { lines = append([]string{existing}, additions...) } rebuilt := text[:blockStart] + "\n" + strings.Join(lines, "\n") + "\n" + text[end:] - if err := os.WriteFile(entry, []byte(rebuilt), 0o644); err != nil { + if err := workspace.AtomicWrite(entry, []byte(rebuilt), 0o644); err != nil { return 0, err } return len(additions), nil diff --git a/internal/cli/clean.go b/internal/cli/clean.go index f38b2bc..e6770ac 100644 --- a/internal/cli/clean.go +++ b/internal/cli/clean.go @@ -26,6 +26,10 @@ func runClean(args []string, templates embed.FS) int { if err := fset.Parse(args); err != nil { return failOnFlagParse(err) } + if err := rejectPositionals("clean", fset); err != nil { + render.Err(err.Error()) + return 1 + } r, err := workspace.Resolve(root) if err != nil { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4122e3b..a54c4a2 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -116,7 +116,7 @@ func prog() string { } func help(w *os.File) { - fmt.Fprintln(w, strings.TrimSpace(helpText())) + _, _ = fmt.Fprintln(w, strings.TrimSpace(helpText())) } func helpText() string { @@ -233,6 +233,16 @@ func parseAction(resource string, args []string) (string, []string, error) { return args[0], args[1:], nil } +// rejectPositionals errors when a command that takes no positional arguments +// received some, so e.g. `csdd init mydir` (a user expecting `git init DIR` +// semantics) fails loudly instead of silently operating on the cwd. +func rejectPositionals(cmd string, fs *flag.FlagSet) error { + if extra := fs.Args(); len(extra) > 0 { + return fmt.Errorf("`%s %s` takes no positional arguments; got %q (did you mean a flag like --root?)", prog(), cmd, extra[0]) + } + return nil +} + // failOnFlagParse short-circuits when a flag parse fails. func failOnFlagParse(err error) int { if err == nil { @@ -245,6 +255,12 @@ func failOnFlagParse(err error) int { return 1 } +// isHelpFlag reports whether an action token is really a request for help, so +// `csdd spec --help` prints usage instead of erroring with "unknown action". +func isHelpFlag(s string) bool { + return s == "-h" || s == "--help" || s == "help" +} + // containsString reports whether needle is in haystack. func containsString(haystack []string, needle string) bool { for _, s := range haystack { diff --git a/internal/cli/cli_extra_test.go b/internal/cli/cli_extra_test.go index 31dbba8..aa8cb71 100644 --- a/internal/cli/cli_extra_test.go +++ b/internal/cli/cli_extra_test.go @@ -6,6 +6,7 @@ import ( "flag" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -367,6 +368,9 @@ func TestSteeringListAuthorTruncation(t *testing.T) { // It works by chmod'ing the workspace tree read-only after init, so any // subsequent write attempt fails at the syscall layer. Skipped under root. func TestWriteOperationsAgainstReadOnlyRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -419,6 +423,9 @@ func TestWriteOperationsAgainstReadOnlyRoot(t *testing.T) { // TestSteeringInitWriteFailure forces steeringInit down its SafeWrite error // branch by deleting the baseline files and then locking the directory. func TestSteeringInitWriteFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -439,6 +446,9 @@ func TestSteeringInitWriteFailure(t *testing.T) { // TestSpecGenerateAndApproveWriteFailures cover SpecGenerate's WriteFile // branch and SpecApprove's saveSpecJSON branch by locking the spec dir. func TestSpecGenerateAndApproveWriteFailures(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -459,6 +469,9 @@ func TestSpecGenerateAndApproveWriteFailures(t *testing.T) { // Making the parent .claude/skills read-only is enough — RemoveAll cannot // unlink entries from a directory it has no write permission on. func TestSkillDeleteFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -476,6 +489,9 @@ func TestSkillDeleteFailure(t *testing.T) { // TestAgentDeleteFailure mirrors TestSkillDeleteFailure for agents. func TestAgentDeleteFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -493,6 +509,9 @@ func TestAgentDeleteFailure(t *testing.T) { // TestSteeringDeleteFailure mirrors the delete-failure pattern for steering. func TestSteeringDeleteFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -510,6 +529,9 @@ func TestSteeringDeleteFailure(t *testing.T) { // TestSpecDeleteFailure covers specDelete's os.RemoveAll error branch. func TestSpecDeleteFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -530,6 +552,9 @@ func TestSpecDeleteFailure(t *testing.T) { // (`skill list` is intentionally excluded: a missing/unreadable skills dir is a // valid "no skills found" result, not an error — SkillRoot no longer creates it.) func TestSkillRootFailures(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -557,6 +582,9 @@ func TestSkillRootFailures(t *testing.T) { // TestAgentRootFailures mirrors TestSkillRootFailures for the agent commands. func TestAgentRootFailures(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission restriction is a no-op on Windows") + } if os.Geteuid() == 0 { t.Skip("running as root bypasses chmod; skipping") } @@ -733,7 +761,7 @@ func TestInitScaffoldsClaudeCodeArtifacts(t *testing.T) { } for _, exe := range []string{".claude/hooks/block-destructive.sh", ".githooks/pre-push"} { info, err := os.Stat(filepath.Join(dir, exe)) - if err != nil || info.Mode()&0o111 == 0 { + if err != nil || (runtime.GOOS != "windows" && info.Mode()&0o111 == 0) { t.Errorf("%s must be executable: err=%v mode=%v", exe, err, info.Mode()) } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5616568..ce9d35a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -504,7 +504,7 @@ func TestSpecInitAndShow(t *testing.T) { if code != 0 { t.Fatalf("spec init: %d", code) } - if !strings.Contains(out, "specs/photo-albums/") { + if !strings.Contains(filepath.ToSlash(out), "specs/photo-albums/") { t.Errorf("expected path in output: %s", out) } if _, err := os.Stat(filepath.Join(dir, "specs/photo-albums/spec.json")); err != nil { diff --git a/internal/cli/color.go b/internal/cli/color.go new file mode 100644 index 0000000..720d58f --- /dev/null +++ b/internal/cli/color.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + "strings" +) + +// agentColors is the closed, case-sensitive set of display colors Claude Code +// honors in a sub-agent's `color` frontmatter field (shown in the task list and +// transcript). Like effortLevels, it is the single source of truth for both the +// membership check and the error message, so the two cannot drift. +var agentColors = []string{"red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"} + +// validateColor reports whether a color value may be written to frontmatter. The +// empty string is accepted and means "omit the key" (no color assigned); any +// other value must appear in agentColors exactly. It never touches the +// filesystem, so callers run it before creating any artifact. +func validateColor(color string) error { + if color == "" { + return nil + } + for _, c := range agentColors { + if color == c { + return nil + } + } + return fmt.Errorf("invalid --color %q: must be one of %s", color, strings.Join(agentColors, ", ")) +} diff --git a/internal/cli/color_test.go b/internal/cli/color_test.go new file mode 100644 index 0000000..0415960 --- /dev/null +++ b/internal/cli/color_test.go @@ -0,0 +1,39 @@ +package cli + +import "testing" + +// TestValidateColor pins the closed, case-sensitive color set Claude Code honors +// in a sub-agent's `color` frontmatter. The empty string is accepted (it means +// "omit the key"); anything else must be one of the eight names exactly. +func TestValidateColor(t *testing.T) { + cases := []struct { + name string + color string + wantErr bool + }{ + {"empty means omit", "", false}, + {"red", "red", false}, + {"blue", "blue", false}, + {"green", "green", false}, + {"yellow", "yellow", false}, + {"purple", "purple", false}, + {"orange", "orange", false}, + {"pink", "pink", false}, + {"cyan", "cyan", false}, + {"out of set", "teal", true}, + {"wrong case Red", "Red", true}, + {"hex not allowed", "#ff0000", true}, + {"whitespace", " red", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateColor(tc.color) + if tc.wantErr && err == nil { + t.Errorf("validateColor(%q): expected error, got nil", tc.color) + } + if !tc.wantErr && err != nil { + t.Errorf("validateColor(%q): expected nil, got %v", tc.color, err) + } + }) + } +} diff --git a/internal/cli/copy.go b/internal/cli/copy.go index d81e784..d196933 100644 --- a/internal/cli/copy.go +++ b/internal/cli/copy.go @@ -50,7 +50,13 @@ func runCopy(args []string, templates embed.FS) int { var force bool addRoot(fset, &root) addForce(fset, &force) - if err := fset.Parse(args); err != nil { + // Use parseFlags (not fset.Parse) so flags may follow the positional, e.g. + // `csdd copy skills/foo --force`. Plain Parse stops at the first non-flag and + // would leave --force unparsed, so --force was silently ignored — and the + // documented `csdd --root PATH copy …` form (rewritten to trailing --root) + // operated on the wrong workspace. + rest, err := parseFlags(fset, args) + if err != nil { return failOnFlagParse(err) } @@ -65,7 +71,6 @@ func runCopy(args []string, templates embed.FS) int { } kinds := copyKinds() - rest := fset.Args() if len(rest) == 0 { return listCopyable(templates, kinds, "") } diff --git a/internal/cli/destroy.go b/internal/cli/destroy.go index e22b1df..f9216d3 100644 --- a/internal/cli/destroy.go +++ b/internal/cli/destroy.go @@ -33,6 +33,10 @@ func runDestroy(args []string, _ embed.FS) int { if err := fset.Parse(args); err != nil { return failOnFlagParse(err) } + if err := rejectPositionals("destroy", fset); err != nil { + render.Err(err.Error()) + return 1 + } r, err := workspace.Resolve(root) if err != nil { diff --git a/internal/cli/gitignore.go b/internal/cli/gitignore.go index 8e083a1..5375df3 100644 --- a/internal/cli/gitignore.go +++ b/internal/cli/gitignore.go @@ -4,18 +4,25 @@ import ( "os" "path/filepath" "strings" + + "github.com/protonspy/csdd/internal/workspace" ) // pinggyTokenFile is the workspace-local store for the pinggy Pro access token. // It is a secret — never committed — so it is always gitignored. const pinggyTokenFile = ".pinggy-token" +// pinggyKnownHostsFile is the workspace-local TOFU known_hosts the tunnel writes +// for pinggy ssh host-key pinning. Not a secret, but workspace-local churn that +// should not be committed. +const pinggyKnownHostsFile = ".pinggy-known_hosts" + // gitignoreTargets lists the root-level csdd artifacts that should not be -// committed — the compiled binary and the pinggy token secret. The binary is -// added only when present; the token file is always ignored because -// `csdd web --pinggy-token` may create it later. +// committed — the compiled binary, the pinggy token secret, and the pinggy +// known_hosts. The binary is added only when present; the pinggy files are +// always ignored because `csdd web` may create them later. func gitignoreTargets(root string) []string { - targets := []string{pinggyTokenFile} + targets := []string{pinggyTokenFile, pinggyKnownHostsFile} for _, name := range []string{"csdd", "csdd.exe"} { if pathExists(filepath.Join(root, name)) { targets = append(targets, name) @@ -67,7 +74,7 @@ func ensureGitignore(root string, names []string) ([]string, error) { for _, e := range toAdd { b.WriteString(e + "\n") } - if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + if err := workspace.AtomicWrite(path, []byte(b.String()), 0o644); err != nil { return nil, err } return toAdd, nil diff --git a/internal/cli/gitignore_test.go b/internal/cli/gitignore_test.go index 8433867..81aff3e 100644 --- a/internal/cli/gitignore_test.go +++ b/internal/cli/gitignore_test.go @@ -128,15 +128,16 @@ func TestEnsureGitignoreRespectsExistingBareEntry(t *testing.T) { // only the artifacts actually present at root. func TestGitignoreTargetsOnlyExisting(t *testing.T) { dir := t.TempDir() - // The pinggy token secret is always ignored, even before it exists. - if got := gitignoreTargets(dir); len(got) != 1 || got[0] != ".pinggy-token" { - t.Errorf("empty dir should yield [.pinggy-token], got %v", got) + // The pinggy token secret and known_hosts are always ignored, even before + // they exist (csdd web may create them later). + if got := gitignoreTargets(dir); len(got) != 2 || got[0] != ".pinggy-token" || got[1] != ".pinggy-known_hosts" { + t.Errorf("empty dir should yield [.pinggy-token .pinggy-known_hosts], got %v", got) } if err := os.WriteFile(filepath.Join(dir, "csdd"), []byte("ELF"), 0o755); err != nil { t.Fatal(err) } got := gitignoreTargets(dir) - if len(got) != 2 || got[0] != ".pinggy-token" || got[1] != "csdd" { - t.Errorf("expected [.pinggy-token csdd], got %v", got) + if len(got) != 3 || got[2] != "csdd" { + t.Errorf("expected [.pinggy-token .pinggy-known_hosts csdd], got %v", got) } } diff --git a/internal/cli/hardening_test.go b/internal/cli/hardening_test.go new file mode 100644 index 0000000..3e2f8c2 --- /dev/null +++ b/internal/cli/hardening_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSpecDeleteRejectsTraversal is the regression for the highest-severity +// finding: `csdd spec delete .. --force` must not RemoveAll the workspace root. +func TestSpecDeleteRejectsTraversal(t *testing.T) { + dir := freshWorkspace(t) + sentinel := filepath.Join(dir, "CLAUDE.md") + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("precondition: CLAUDE.md should exist: %v", err) + } + for _, bad := range []string{"..", "../..", "../src"} { + code, _, errOut := run(t, "spec", "delete", bad, "--force", "--root", dir) + if code == 0 { + t.Errorf("spec delete %q should fail, got code 0", bad) + } + if !strings.Contains(errOut, "invalid") { + t.Errorf("spec delete %q should report an invalid name, got %q", bad, errOut) + } + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("workspace was damaged by a traversal delete: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".claude")); err != nil { + t.Fatalf(".claude/ was damaged by a traversal delete: %v", err) + } +} + +func TestSkillDeleteRejectsTraversal(t *testing.T) { + dir := freshWorkspace(t) + code, _, _ := run(t, "skill", "delete", "..", "--force", "--root", dir) + if code == 0 { + t.Error("skill delete .. should not succeed") + } + if _, err := os.Stat(filepath.Join(dir, ".claude", "skills")); err != nil { + t.Fatalf(".claude/skills was damaged: %v", err) + } +} + +func TestSteeringDeleteRejectsTraversal(t *testing.T) { + dir := freshWorkspace(t) + code, _, _ := run(t, "steering", "delete", "../../CLAUDE", "--force", "--root", dir) + if code == 0 { + t.Error("steering delete ../../CLAUDE should not succeed") + } + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil { + t.Fatalf("CLAUDE.md was deleted via steering traversal: %v", err) + } +} + +func TestInitRejectsStrayPositional(t *testing.T) { + dir := t.TempDir() + code, _, errOut := run(t, "init", "somedir", "--root", dir) + if code == 0 { + t.Error("`csdd init somedir` should fail (no positional args), not silently scaffold cwd") + } + if !strings.Contains(errOut, "positional") { + t.Errorf("expected a positional-args error, got %q", errOut) + } +} + +func TestSpecValidateJSON(t *testing.T) { + dir := freshWorkspace(t) + if code, _, e := run(t, "spec", "init", "photo-albums", "--root", dir); code != 0 { + t.Fatalf("spec init failed: %s", e) + } + if code, _, e := run(t, "spec", "generate", "photo-albums", "--artifact", "requirements", "--root", dir); code != 0 { + t.Fatalf("generate failed: %s", e) + } + code, out, _ := run(t, "spec", "validate", "photo-albums", "--json", "--root", dir) + _ = code + var payload validationJSON + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("validate --json did not emit valid JSON: %v\noutput=%q", err, out) + } + if payload.Target != "photo-albums" { + t.Errorf("json target = %q, want photo-albums", payload.Target) + } +} + +func TestSpecListJSON(t *testing.T) { + dir := freshWorkspace(t) + run(t, "spec", "init", "alpha", "--root", dir) + code, out, _ := run(t, "spec", "list", "--json", "--root", dir) + if code != 0 { + t.Fatalf("spec list --json failed: %d", code) + } + var rows []specSummaryJSON + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("spec list --json invalid: %v\n%q", err, out) + } + if len(rows) != 1 || rows[0].Feature != "alpha" { + t.Errorf("unexpected list payload: %+v", rows) + } +} + +// TestApprovalDriftDetected covers the approval-gate content binding: editing an +// approved artifact after approval must surface a drift issue. +func TestApprovalDriftDetected(t *testing.T) { + dir := freshWorkspace(t) + run(t, "spec", "init", "feat", "--root", dir) + if code, _, e := run(t, "spec", "generate", "feat", "--artifact", "requirements", "--root", dir); code != 0 { + t.Fatalf("generate failed: %s", e) + } + // Overwrite requirements.md with a valid EARS criterion so approval passes. + reqPath := filepath.Join(dir, "specs", "feat", "requirements.md") + valid := "# Requirements\n\n### Requirement 1: R\n\n**Acceptance Criteria**\n1. WHEN a user acts THE SYSTEM SHALL respond.\n" + if err := os.WriteFile(reqPath, []byte(valid), 0o644); err != nil { + t.Fatal(err) + } + if code, _, e := run(t, "spec", "approve", "feat", "--phase", "requirements", "--root", dir); code != 0 { + t.Fatalf("approve failed: %s", e) + } + // No drift right after approval. + if code, _, _ := run(t, "spec", "validate", "feat", "--root", dir); code != 0 { + t.Errorf("validate should pass immediately after approval, got %d", code) + } + // Edit the approved artifact → drift. + if err := os.WriteFile(reqPath, []byte(valid+"\n2. WHEN b THE SYSTEM SHALL c.\n"), 0o644); err != nil { + t.Fatal(err) + } + code, out, errOut := run(t, "spec", "validate", "feat", "--root", dir) + combined := out + errOut + if code == 0 || !strings.Contains(combined, "after approval") { + t.Errorf("edited-after-approval drift not reported: code=%d out=%q", code, combined) + } +} diff --git a/internal/cli/init.go b/internal/cli/init.go index 52432ca..0d6773a 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" "time" @@ -28,6 +29,10 @@ func runInit(args []string, templates embed.FS) int { if err := fs.Parse(args); err != nil { return failOnFlagParse(err) } + if err := rejectPositionals("init", fs); err != nil { + render.Err(err.Error()) + return 1 + } exSet, err := parseExclusions(exclude.values) if err != nil { render.Err(err.Error()) @@ -347,9 +352,10 @@ func initWorkspace(root string, opts initOptions, templates embed.FS) (initCount // Baseline steering files, imported into CLAUDE.md as always-on memory. if opts.withBaseline && !opts.skip("steering") { - var names []string - for name, tplPath := range standardSteeringTemplates() { - content, err := templater.Static(templates, tplPath) + tpls := standardSteeringTemplates() + names := standardSteeringOrder() + for _, name := range names { + content, err := templater.Static(templates, tpls[name]) if err != nil { return c, err } @@ -360,7 +366,6 @@ func initWorkspace(root string, opts initOptions, templates embed.FS) (initCount if created { c.files++ } - names = append(names, name) } // A no-op when CLAUDE.md was excluded (there is nothing to wire into). if _, err := ensureSteeringImports(root, names...); err != nil { @@ -386,3 +391,17 @@ func standardSteeringTemplates() map[string]string { "api-conventions.md": "templates/steering/api-conventions.md.tmpl", } } + +// standardSteeringOrder returns the baseline steering file names in a stable +// sorted order. Both scaffolders iterate this instead of the map directly, so +// the files are created — and the CLAUDE.md @-import block is written — in the +// same order on every run and machine (Go map iteration is randomized). +func standardSteeringOrder() []string { + m := standardSteeringTemplates() + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/cli/jsonout.go b/internal/cli/jsonout.go new file mode 100644 index 0000000..5ed080e --- /dev/null +++ b/internal/cli/jsonout.go @@ -0,0 +1,62 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/protonspy/csdd/internal/render" + "github.com/protonspy/csdd/internal/validator" +) + +// addJSON binds the conventional --json flag. Every command that has a +// machine-readable variant uses this so the flag name and help text are +// identical across the CLI — the surface an AI agent scripts against. +func addJSON(fs *flag.FlagSet, dst *bool) { + fs.BoolVar(dst, "json", false, "Emit machine-readable JSON on stdout instead of the human table.") +} + +// emitJSON writes v as indented JSON to stdout and returns a process exit code. +// Diagnostics still go to stderr via render, so stdout stays a clean JSON stream +// an agent can parse. +func emitJSON(v any) int { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + render.Err(err.Error()) + return 1 + } + fmt.Println(string(b)) + return 0 +} + +// issueJSON is the stable machine-readable shape of a validation issue. It uses +// explicit snake_case keys so the schema is decoupled from validator.Issue's Go +// field names. +type issueJSON struct { + File string `json:"file"` + Line int `json:"line,omitempty"` + Msg string `json:"message"` +} + +func issuesToJSON(in []validator.Issue) []issueJSON { + out := make([]issueJSON, 0, len(in)) + for _, i := range in { + out = append(out, issueJSON{File: i.File, Line: i.Line, Msg: i.Msg}) + } + return out +} + +// validationJSON is the payload for every `... validate --json` command. +type validationJSON struct { + Target string `json:"target"` + OK bool `json:"ok"` + Issues []issueJSON `json:"issues"` +} + +// specSummaryJSON is one row of `spec list --json`. +type specSummaryJSON struct { + Feature string `json:"feature"` + Phase string `json:"phase"` + Approved []string `json:"approved"` + Ready bool `json:"ready_for_implementation"` +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 5681873..3b1252c 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "os" - "path/filepath" "sort" "strings" @@ -44,6 +43,10 @@ func runMCP(args []string) int { render.Err(err.Error()) return 1 } + if isHelpFlag(action) { + help(os.Stdout) + return 0 + } switch action { case "add": return mcpAdd(rest) @@ -105,10 +108,7 @@ func saveMCP(path string, cfg MCPConfig) error { return err } b = append(b, '\n') - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - return os.WriteFile(path, b, 0o644) + return workspace.AtomicWrite(path, b, 0o644) } // MCPAddOptions is the headless equivalent shared by the CLI and the TUI. @@ -271,6 +271,26 @@ func mcpList(args []string) int { return code } cfg := r.cfg + if r.jsonOut { + type serverJSON struct { + Name string `json:"name"` + Transport string `json:"transport"` + Disabled bool `json:"disabled"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + URL string `json:"url,omitempty"` + } + out := []serverJSON{} + for _, n := range sortedServerNames(cfg) { + s := cfg.MCPServers[n] + transport := "stdio" + if s.URL != "" { + transport = s.Type + } + out = append(out, serverJSON{Name: n, Transport: transport, Disabled: s.Disabled, Command: s.Command, Args: s.Args, URL: s.URL}) + } + return emitJSON(out) + } if len(cfg.MCPServers) == 0 { render.Info("no mcp servers configured") return 0 @@ -429,6 +449,13 @@ func mcpValidate(args []string) int { return code } issues := validateMCPConfig(res.cfg) + if res.jsonOut { + emitJSON(validationJSON{Target: "mcp.json", OK: len(issues) == 0, Issues: issuesToJSON(issues)}) + if len(issues) > 0 { + return 2 + } + return 0 + } if len(issues) == 0 { render.OK(fmt.Sprintf("mcp.json valid (%d server(s))", len(res.cfg.MCPServers))) return 0 @@ -487,15 +514,20 @@ func sortedServerNames(cfg MCPConfig) []string { } // mcpResult bundles a resolved root + loaded config for the read commands. +// jsonOut carries the shared --json flag so list/show/validate can emit +// machine-readable output without each re-parsing flags. type mcpResult struct { - root string - cfg MCPConfig + root string + cfg MCPConfig + jsonOut bool } func mcpResolveAndLoad(args []string) (mcpResult, []string, int) { fs := flag.NewFlagSet("mcp", flag.ContinueOnError) var root string + var jsonOut bool addRoot(fs, &root) + addJSON(fs, &jsonOut) positionals, err := parseFlags(fs, args) if err != nil { return mcpResult{}, nil, failOnFlagParse(err) @@ -515,7 +547,7 @@ func mcpResolveAndLoad(args []string) (mcpResult, []string, int) { render.Err(err.Error()) return mcpResult{}, nil, 1 } - return mcpResult{root: r, cfg: cfg}, positionals, 0 + return mcpResult{root: r, cfg: cfg, jsonOut: jsonOut}, positionals, 0 } func mcpResolveLoadNamed(args []string, action string) (mcpResult, string, int) { diff --git a/internal/cli/skill.go b/internal/cli/skill.go index 19eb745..a85468b 100644 --- a/internal/cli/skill.go +++ b/internal/cli/skill.go @@ -22,6 +22,10 @@ func runSkill(args []string, templates embed.FS) int { render.Err(err.Error()) return 1 } + if isHelpFlag(action) { + help(os.Stdout) + return 0 + } switch action { case "create": return skillCreate(rest, templates) @@ -173,6 +177,11 @@ func skillList(args []string) int { } func findSkill(root, name string) (string, bool) { + // Guard before joining: a name like ".." would resolve findSkill to .claude/ + // itself, which skillDelete would then RemoveAll. + if err := workspace.SafeName(name, "skill"); err != nil { + return "", false + } base := workspace.SkillRoot(root) candidate := filepath.Join(base, name) if info, err := os.Stat(candidate); err == nil && info.IsDir() { @@ -204,7 +213,7 @@ func skillShow(args []string) int { return 1 } fmt.Println(render.Bold(workspace.Relative(r, sdir))) - filepath.Walk(sdir, func(path string, info os.FileInfo, err error) error { + _ = filepath.Walk(sdir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } @@ -309,13 +318,15 @@ func safeSkillChildPath(base, rel string) (string, error) { func skillValidate(args []string) int { fs := flag.NewFlagSet("skill validate", flag.ContinueOnError) var root string + var jsonOut bool addRoot(fs, &root) + addJSON(fs, &jsonOut) positionals, err := parseFlags(fs, args) if err != nil { return failOnFlagParse(err) } if len(positionals) < 1 { - render.Err("usage: " + prog() + " skill validate NAME") + render.Err("usage: " + prog() + " skill validate NAME [--json]") return 1 } r, err := workspace.Resolve(root) @@ -330,6 +341,17 @@ func skillValidate(args []string) int { return 1 } issues, lines, tokens := validator.ValidateSkill(sdir, name) + if jsonOut { + emitJSON(struct { + validationJSON + Lines int `json:"lines"` + Tokens int `json:"tokens"` + }{validationJSON{Target: name, OK: len(issues) == 0, Issues: issuesToJSON(issues)}, lines, tokens}) + if len(issues) > 0 { + return 2 + } + return 0 + } if len(issues) == 0 { render.OK(fmt.Sprintf("%s: skill valid (%d lines, ~%d tokens)", name, lines, tokens)) return 0 diff --git a/internal/cli/spec.go b/internal/cli/spec.go index ad8abc4..6b6e399 100644 --- a/internal/cli/spec.go +++ b/internal/cli/spec.go @@ -16,6 +16,7 @@ import ( "time" "github.com/protonspy/csdd/internal/frontmatter" + "github.com/protonspy/csdd/internal/manifest" "github.com/protonspy/csdd/internal/paths" "github.com/protonspy/csdd/internal/render" "github.com/protonspy/csdd/internal/session" @@ -24,8 +25,13 @@ import ( "github.com/protonspy/csdd/internal/workspace" ) +// specSchemaVersion is the current spec.json schema. It is written on every save +// so a future csdd can detect and migrate older layouts instead of guessing. +const specSchemaVersion = 1 + // SpecJSON mirrors the schema produced by the Python reference implementation. type SpecJSON struct { + SchemaVersion int `json:"schema_version,omitempty"` FeatureName string `json:"feature_name"` Language string `json:"language"` Phase string `json:"phase"` @@ -33,6 +39,18 @@ type SpecJSON struct { Approvals map[string]ApprovalFlag `json:"approvals"` ReadyForImplementation bool `json:"ready_for_implementation"` CreatedAt string `json:"created_at"` + + // extra preserves any keys a newer csdd wrote that this binary does not model, + // so round-tripping spec.json through an older binary never silently drops + // them. Unexported ⇒ ignored by encoding/json; merged back in saveSpecJSON. + extra map[string]json.RawMessage +} + +// knownSpecKeys are the JSON object keys SpecJSON models directly; anything else +// on disk is captured into SpecJSON.extra and round-tripped untouched. +var knownSpecKeys = []string{ + "schema_version", "feature_name", "language", "phase", "development_flow", + "approvals", "ready_for_implementation", "created_at", } // defaultDevelopmentFlow is the flow assumed when none is selected and steering @@ -95,10 +113,15 @@ func resolveDefaultFlow(root string) string { return defaultDevelopmentFlow } -// ApprovalFlag tracks generation + approval state for a phase. +// ApprovalFlag tracks generation + approval state for a phase. ContentHash binds +// an approval to the exact artifact content that was approved: if the phase's +// requirements.md / design.md / tasks.md is edited by hand after approval, the +// stored hash no longer matches and the drift is reported — closing the loophole +// where a spec-driven gate could be bypassed by editing the file post-approval. type ApprovalFlag struct { - Generated bool `json:"generated"` - Approved bool `json:"approved"` + Generated bool `json:"generated"` + Approved bool `json:"approved"` + ContentHash string `json:"content_hash,omitempty"` } func runSpec(args []string, templates embed.FS) int { @@ -107,6 +130,10 @@ func runSpec(args []string, templates embed.FS) int { render.Err(err.Error()) return 1 } + if isHelpFlag(action) { + help(os.Stdout) + return 0 + } switch action { case "init": return specInit(rest, templates) @@ -218,7 +245,9 @@ func specInit(args []string, templates embed.FS) int { func specList(args []string) int { fs := flag.NewFlagSet("spec list", flag.ContinueOnError) var root string + var jsonOut bool addRoot(fs, &root) + addJSON(fs, &jsonOut) _, err := parseFlags(fs, args) if err != nil { return failOnFlagParse(err) @@ -240,6 +269,7 @@ func specList(args []string) int { } type row struct{ feature, phase, approved, ready string } var rows []row + var jsonRows []specSummaryJSON maxName := len("feature") for _, e := range entries { if !e.IsDir() { @@ -247,13 +277,18 @@ func specList(args []string) int { } data, err := loadSpecJSON(filepath.Join(base, e.Name())) if err != nil { + // A dir with no spec.json isn't a spec — skip it quietly. But a spec.json + // that fails to parse is corruption the user must see, not hide. + if !os.IsNotExist(err) { + render.Warn("skipping spec '" + e.Name() + "': " + err.Error()) + } continue } ready := "no" if data.ReadyForImplementation { ready = "yes" } - var approved []string + approved := []string{} for k, v := range data.Approvals { if v.Approved { approved = append(approved, k) @@ -265,10 +300,20 @@ func specList(args []string) int { appStr = strings.Join(approved, ",") } rows = append(rows, row{e.Name(), data.Phase, appStr, ready}) + jsonRows = append(jsonRows, specSummaryJSON{ + Feature: e.Name(), + Phase: data.Phase, + Approved: approved, + Ready: data.ReadyForImplementation, + }) if len(e.Name()) > maxName { maxName = len(e.Name()) } } + if jsonOut { + sort.Slice(jsonRows, func(i, j int) bool { return jsonRows[i].Feature < jsonRows[j].Feature }) + return emitJSON(jsonRows) + } if len(rows) == 0 { render.Info("no specs found") return 0 @@ -284,13 +329,15 @@ func specList(args []string) int { func specShow(args []string) int { fs := flag.NewFlagSet("spec show", flag.ContinueOnError) var root string + var jsonOut bool addRoot(fs, &root) + addJSON(fs, &jsonOut) positionals, err := parseFlags(fs, args) if err != nil { return failOnFlagParse(err) } if len(positionals) < 1 { - render.Err("usage: " + prog() + " spec show FEATURE") + render.Err("usage: " + prog() + " spec show FEATURE [--json]") return 1 } r, err := workspace.Resolve(root) @@ -299,6 +346,10 @@ func specShow(args []string) int { return 1 } feature := positionals[0] + if err := workspace.SafeName(feature, "feature"); err != nil { + render.Err(err.Error()) + return 1 + } sdir := filepath.Join(paths.Specs(r), feature) if !pathExists(sdir) { render.Err("spec not found: " + feature) @@ -309,6 +360,9 @@ func specShow(args []string) int { render.Err(err.Error()) return 1 } + if jsonOut { + return emitJSON(data) + } fmt.Println(render.Bold("feature: " + data.FeatureName)) fmt.Println("phase: " + data.Phase) fmt.Println("language: " + data.Language) @@ -395,6 +449,9 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error { if err != nil { return err } + if err := workspace.SafeName(opts.Feature, "feature"); err != nil { + return err + } sdir := filepath.Join(paths.Specs(r), opts.Feature) if !pathExists(sdir) { return fmt.Errorf("spec not found: %s. Run `%s spec init %s` first", opts.Feature, prog(), opts.Feature) @@ -409,8 +466,8 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error { if opts.Artifact == "tasks" { prev = "design" } - if !data.Approvals[prev].Approved && !opts.Force { - return fmt.Errorf("phase gate: '%s' must be approved before generating '%s'. Use --force only for explicitly fast-track / Quick Plan flows", prev, opts.Artifact) + if !phaseApprovedAndCurrent(sdir, data, prev) && !opts.Force { + return fmt.Errorf("phase gate: '%s' must be approved (and unchanged since) before generating '%s'. Use --force only for explicitly fast-track / Quick Plan flows", prev, opts.Artifact) } } templateMap := map[string][2]string{ @@ -442,6 +499,7 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error { if a, ok := data.Approvals[opts.Artifact]; ok { a.Generated = true a.Approved = false + a.ContentHash = "" // regeneration invalidates any prior approval binding data.Approvals[opts.Artifact] = a } if tracked { @@ -500,6 +558,9 @@ func SpecApprove(opts SpecApproveOptions) error { if err != nil { return err } + if err := workspace.SafeName(opts.Feature, "feature"); err != nil { + return err + } sdir := filepath.Join(paths.Specs(r), opts.Feature) if !pathExists(sdir) { return fmt.Errorf("spec not found: %s", opts.Feature) @@ -515,11 +576,11 @@ func SpecApprove(opts SpecApproveOptions) error { if !state.Generated { return fmt.Errorf("cannot approve '%s': not generated yet", opts.Phase) } - if prev := previousPhase(opts.Phase); prev != "" && !data.Approvals[prev].Approved { + if prev := previousPhase(opts.Phase); prev != "" && !phaseApprovedAndCurrent(sdir, data, prev) { if !opts.Force { - return fmt.Errorf("phase gate: '%s' must be approved before approving '%s'", prev, opts.Phase) + return fmt.Errorf("phase gate: '%s' must be approved (and unchanged since) before approving '%s'", prev, opts.Phase) } - render.Warn("approval forced despite missing prior approval for '" + prev + "'") + render.Warn("approval forced despite missing/stale prior approval for '" + prev + "'") } issues := validator.ValidateSpec(sdir, validator.Phase(opts.Phase)) if len(issues) > 0 { @@ -532,6 +593,9 @@ func SpecApprove(opts SpecApproveOptions) error { render.Warn("approval forced despite validation issues") } state.Approved = true + if h, err := phaseContentHash(sdir, opts.Phase); err == nil { + state.ContentHash = h + } data.Approvals[opts.Phase] = state data.Phase = opts.Phase + "-approved" ready := true @@ -552,6 +616,76 @@ func SpecApprove(opts SpecApproveOptions) error { return nil } +// phaseArtifact maps an approvable phase to the artifact whose content its +// approval certifies. +func phaseArtifact(phase string) string { + switch phase { + case "requirements": + return "requirements.md" + case "design": + return "design.md" + case "tasks": + return "tasks.md" + } + return "" +} + +// phaseContentHash returns the line-ending-normalized content hash of a phase's +// artifact. Normalization means a CRLF re-checkout never looks like drift. +func phaseContentHash(specDir, phase string) (string, error) { + name := phaseArtifact(phase) + if name == "" { + return "", fmt.Errorf("no artifact for phase %q", phase) + } + b, err := os.ReadFile(filepath.Join(specDir, name)) + if err != nil { + return "", err + } + return manifest.Hash(string(b)), nil +} + +// phaseApprovedAndCurrent reports whether a phase is approved AND its artifact +// has not been edited since (no drift). A drifted approval must not satisfy a +// phase gate — otherwise a post-approval hand-edit would let the next phase +// proceed on a stale checkpoint. Approvals with no stored hash (written by an +// older csdd) are trusted, since drift can't be detected for them. +func phaseApprovedAndCurrent(specDir string, s SpecJSON, phase string) bool { + a, ok := s.Approvals[phase] + if !ok || !a.Approved { + return false + } + if a.ContentHash == "" { + return true + } + h, err := phaseContentHash(specDir, phase) + return err == nil && h == a.ContentHash +} + +// approvalDriftIssues reports any phase whose artifact was edited after approval, +// so a hand-edit can't silently ride on a stale approval. Approvals recorded by +// older csdd versions (no stored hash) are skipped — no false positives. +func approvalDriftIssues(specDir string, s SpecJSON) []validator.Issue { + var out []validator.Issue + for _, phase := range workspace.SpecPhases { + a, ok := s.Approvals[phase] + if !ok || !a.Approved || a.ContentHash == "" { + continue + } + h, err := phaseContentHash(specDir, phase) + if err != nil { + continue + } + if h != a.ContentHash { + out = append(out, validator.Issue{ + File: phaseArtifact(phase), + Msg: fmt.Sprintf("edited after approval — the '%s' approval no longer certifies this content; re-approve (`%s spec approve %s --phase %s`) or regenerate", + phase, prog(), s.FeatureName, phase), + }) + } + } + return out +} + func previousPhase(phase string) string { switch phase { case "design": @@ -566,13 +700,15 @@ func previousPhase(phase string) string { func specValidate(args []string) int { fs := flag.NewFlagSet("spec validate", flag.ContinueOnError) var root string + var jsonOut bool addRoot(fs, &root) + addJSON(fs, &jsonOut) positionals, err := parseFlags(fs, args) if err != nil { return failOnFlagParse(err) } if len(positionals) < 1 { - render.Err("usage: " + prog() + " spec validate FEATURE") + render.Err("usage: " + prog() + " spec validate FEATURE [--json]") return 1 } r, err := workspace.Resolve(root) @@ -581,6 +717,10 @@ func specValidate(args []string) int { return 1 } feature := positionals[0] + if err := workspace.SafeName(feature, "feature"); err != nil { + render.Err(err.Error()) + return 1 + } sdir := filepath.Join(paths.Specs(r), feature) if !pathExists(sdir) { render.Err("spec not found: " + feature) @@ -593,6 +733,16 @@ func specValidate(args []string) int { } phase, preflight := validationScope(sdir, data) issues := append(preflight, validator.ValidateSpec(sdir, phase)...) + issues = append(issues, approvalDriftIssues(sdir, data)...) + if jsonOut { + // Exit code still encodes the result (2 = issues) so an agent can branch on + // $? without parsing, while stdout carries the details. + emitJSON(validationJSON{Target: feature, OK: len(issues) == 0, Issues: issuesToJSON(issues)}) + if len(issues) > 0 { + return 2 + } + return 0 + } if len(issues) == 0 { render.OK(feature + ": validation passed") return 0 @@ -637,6 +787,10 @@ func specDelete(args []string) int { return 1 } feature := positionals[0] + if err := workspace.SafeName(feature, "feature"); err != nil { + render.Err(err.Error()) + return 1 + } if !force { render.Err("refusing to delete spec '" + feature + "' without --force") return 1 @@ -692,6 +846,10 @@ func specTestReport(args []string) int { return 1 } feature := positionals[0] + if err := workspace.SafeName(feature, "feature"); err != nil { + render.Err(err.Error()) + return 1 + } r, err := workspace.Resolve(root) if err != nil { render.Err(err.Error()) @@ -900,12 +1058,23 @@ func nz(n int) int { func loadSpecJSON(specDir string) (SpecJSON, error) { var s SpecJSON - data, err := os.ReadFile(filepath.Join(specDir, "spec.json")) + path := filepath.Join(specDir, "spec.json") + data, err := os.ReadFile(path) if err != nil { return s, err } if err := json.Unmarshal(data, &s); err != nil { - return s, err + return s, fmt.Errorf("parse %s: %w", path, err) + } + // Capture keys this binary does not model so save() can round-trip them. + var all map[string]json.RawMessage + if json.Unmarshal(data, &all) == nil { + for _, k := range knownSpecKeys { + delete(all, k) + } + if len(all) > 0 { + s.extra = all + } } if s.Approvals == nil { s.Approvals = map[string]ApprovalFlag{} @@ -914,10 +1083,35 @@ func loadSpecJSON(specDir string) (SpecJSON, error) { } func saveSpecJSON(specDir string, s SpecJSON) error { - b, err := json.MarshalIndent(s, "", " ") + if s.SchemaVersion == 0 { + s.SchemaVersion = specSchemaVersion + } + var b []byte + var err error + if len(s.extra) == 0 { + // Fast path: keep the clean, field-ordered layout with no diff churn. + b, err = json.MarshalIndent(s, "", " ") + } else { + // Forward-compat path: merge unknown keys back so an older binary never + // drops fields a newer csdd wrote. + known, mErr := json.Marshal(s) + if mErr != nil { + return mErr + } + merged := map[string]json.RawMessage{} + if err := json.Unmarshal(known, &merged); err != nil { + return err + } + for k, v := range s.extra { + if _, ok := merged[k]; !ok { + merged[k] = v + } + } + b, err = json.MarshalIndent(merged, "", " ") + } if err != nil { return err } b = append(b, '\n') - return os.WriteFile(filepath.Join(specDir, "spec.json"), b, 0o644) + return workspace.AtomicWrite(filepath.Join(specDir, "spec.json"), b, 0o644) } diff --git a/internal/cli/steering.go b/internal/cli/steering.go index 14415ef..11372be 100644 --- a/internal/cli/steering.go +++ b/internal/cli/steering.go @@ -23,6 +23,10 @@ func runSteering(args []string, templates embed.FS) int { render.Err(err.Error()) return 1 } + if isHelpFlag(action) { + help(os.Stdout) + return 0 + } switch action { case "init": return steeringInit(rest, templates) @@ -61,9 +65,10 @@ func steeringInit(args []string, templates embed.FS) int { return 1 } created := 0 - var names []string - for name, tpl := range standardSteeringTemplates() { - content, err := templater.Static(templates, tpl) + tpls := standardSteeringTemplates() + names := standardSteeringOrder() + for _, name := range names { + content, err := templater.Static(templates, tpls[name]) if err != nil { render.Err(err.Error()) return 1 @@ -81,7 +86,6 @@ func steeringInit(args []string, templates embed.FS) int { } else { render.Info("skipped " + rel + " (exists)") } - names = append(names, name) } render.Info("standard steering files created: " + intStr(created)) if added, err := ensureSteeringImports(r, names...); err != nil { @@ -226,6 +230,7 @@ func steeringList(args []string) int { } data, err := os.ReadFile(filepath.Join(sdir, e.Name())) if err != nil { + render.Warn("skipping steering '" + e.Name() + "': " + err.Error()) continue } fm := frontmatter.Parse(string(data)) @@ -273,6 +278,10 @@ func steeringShow(args []string) int { render.Err(err.Error()) return 1 } + if err := workspace.SafeName(positionals[0], "steering"); err != nil { + render.Err(err.Error()) + return 1 + } sdir, err := workspace.SteeringDir(r) if err != nil { render.Err(err.Error()) @@ -299,10 +308,14 @@ func steeringDelete(args []string) int { return failOnFlagParse(err) } if len(positionals) < 1 { - render.Err("usage: " + prog() + " steering delete NAME") + render.Err("usage: " + prog() + " steering delete NAME [--force]") return 1 } name := positionals[0] + if err := workspace.SafeName(name, "steering"); err != nil { + render.Err(err.Error()) + return 1 + } r, err := workspace.Resolve(root) if err != nil { render.Err(err.Error()) @@ -333,7 +346,9 @@ func steeringDelete(args []string) int { func steeringValidate(args []string) int { fs := flag.NewFlagSet("steering validate", flag.ContinueOnError) var root string + var jsonOut bool addRoot(fs, &root) + addJSON(fs, &jsonOut) positionals, err := parseFlags(fs, args) if err != nil { return failOnFlagParse(err) @@ -351,12 +366,27 @@ func steeringValidate(args []string) int { name := "" if len(positionals) >= 1 { name = positionals[0] + if err := workspace.SafeName(name, "steering"); err != nil { + render.Err(err.Error()) + return 1 + } } issues, err := validator.ValidateSteering(sdir, name) if err != nil { render.Err(err.Error()) return 1 } + target := name + if target == "" { + target = "steering" + } + if jsonOut { + emitJSON(validationJSON{Target: target, OK: len(issues) == 0, Issues: issuesToJSON(issues)}) + if len(issues) > 0 { + return 2 + } + return 0 + } if len(issues) == 0 { render.OK("all steering files valid") return 0 diff --git a/internal/cli/update.go b/internal/cli/update.go index 19eec1a..60a01ca 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -133,6 +133,10 @@ func runUpdate(args []string, templates embed.FS) int { if err := fset.Parse(args); err != nil { return failOnFlagParse(err) } + if err := rejectPositionals("update", fset); err != nil { + render.Err(err.Error()) + return 1 + } root, err := workspace.Resolve(opts.root) if err != nil { @@ -162,17 +166,25 @@ func runUpdate(args []string, templates embed.FS) int { // them — but only interactively. Non-interactive runs (CI, agents) keep the // safe-by-backup behaviour: apply and preserve the old copy as .old. conflicts := plan.countConflicts() - if conflicts > 0 && !opts.force && !opts.yes && stdinIsInteractive() { - render.Warn(fmt.Sprintf("%d file(s) you edited differ from this version and would be overridden:", conflicts)) + if conflicts > 0 && !opts.force && !opts.yes { + render.Warn(fmt.Sprintf("%d file(s) you edited differ from this version:", conflicts)) for _, c := range plan.changes { if c.kind == kindConflict { render.Info(" ! " + c.rel) } } render.Info("Your current versions are saved as .old before the new ones are written.") - if !confirm("Override these files?") { - opts.skipConflicts = true - render.Info("Keeping your versions untouched. Re-run with --yes to override.") + if stdinIsInteractive() { + if !confirm("Override these files?") { + opts.skipConflicts = true + render.Info("Keeping your versions untouched. Re-run with --yes to override.") + } + } else { + // Non-interactive (CI, an agent driving the CLI): proceed, but never + // silently — the conflicts were just listed above and each edited file + // is preserved as .old. Pass --yes to suppress this notice, or answer + // interactively to choose per run. + render.Info("Non-interactive: applying new versions; your edits are kept as .old. (Pass --yes to acknowledge, or run interactively to choose.)") } } @@ -287,12 +299,17 @@ func updateWorkspace(opts updateOptions, templates embed.FS, now time.Time) (upd res.changes = append(res.changes, ch) continue } - // Preserve the user's copy as .old, then write the new version. + // Preserve the user's copy as .old, then write the new version. Carry the + // original file's mode over so a backed-up hook script stays executable. if !opts.force { old := nextOldPath(f.Abs) ch.backup = filepath.ToSlash(workspace.Relative(opts.root, old)) if !opts.dryRun { - if err := os.WriteFile(old, diskBytes, 0o644); err != nil { + mode := os.FileMode(0o644) + if info, statErr := os.Stat(f.Abs); statErr == nil { + mode = info.Mode().Perm() + } + if err := os.WriteFile(old, diskBytes, mode); err != nil { return res, err } } diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index 1afac73..b9ce796 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -118,8 +118,8 @@ func TestUpdatePreservesManagedAgentModelEffort(t *testing.T) { agent := filepath.Join(dir, ".claude", "agents", "implementer.md") withOverrides := strings.Replace( readFile(t, agent), - "tools: Read, Grep, Glob, Edit, Write, Bash\n---", - "tools: Read, Grep, Glob, Edit, Write, Bash\nmodel: opus\neffort: high\n---", + "tools: Read, Grep, Glob, Edit, Write, Bash\n", + "tools: Read, Grep, Glob, Edit, Write, Bash\nmodel: opus\neffort: high\n", 1, ) if err := os.WriteFile(agent, []byte(withOverrides), 0o644); err != nil { diff --git a/internal/cli/web_test.go b/internal/cli/web_test.go index 8c5c0c4..b7c1ba6 100644 --- a/internal/cli/web_test.go +++ b/internal/cli/web_test.go @@ -3,6 +3,7 @@ package cli import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -41,7 +42,7 @@ func TestResolvePinggyTokenPersistsAndGitignores(t *testing.T) { if strings.TrimSpace(string(saved)) != "TOK123" { t.Errorf("saved token = %q", saved) } - if fi, _ := os.Stat(filepath.Join(dir, ".pinggy-token")); fi != nil && fi.Mode().Perm() != 0o600 { + if fi, _ := os.Stat(filepath.Join(dir, ".pinggy-token")); fi != nil && runtime.GOOS != "windows" && fi.Mode().Perm() != 0o600 { t.Errorf("token file perms = %v, want 0600", fi.Mode().Perm()) } if gi, _ := os.ReadFile(filepath.Join(dir, ".gitignore")); !strings.Contains(string(gi), ".pinggy-token") { diff --git a/internal/frontmatter/frontmatter.go b/internal/frontmatter/frontmatter.go index 7b6cdf4..b8848a6 100644 --- a/internal/frontmatter/frontmatter.go +++ b/internal/frontmatter/frontmatter.go @@ -5,6 +5,8 @@ package frontmatter import ( "strings" + + "github.com/protonspy/csdd/internal/textutil" ) // Frontmatter holds parsed key/value pairs plus the remaining markdown body. @@ -14,8 +16,13 @@ type Frontmatter struct { } // Parse extracts YAML frontmatter delimited by `---` fences at the head of text. -// When no frontmatter is present, the entire text is returned in Body. +// When no frontmatter is present, the entire text is returned in Body. Input is +// line-ending normalized and BOM-stripped first, so a CRLF file written by a +// Windows editor (or a file Notepad prefixed with a UTF-8 BOM) parses identically +// to an LF one — otherwise the opening `---` fence would fail to match and the +// whole file would be treated as body with no fields. func Parse(text string) Frontmatter { + text = textutil.NormalizeNewlines(text) lines := strings.Split(text, "\n") if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { return Frontmatter{Fields: map[string]any{}, Body: text} @@ -31,23 +38,103 @@ func Parse(text string) Frontmatter { return Frontmatter{Fields: map[string]any{}, Body: text} } fields := map[string]any{} - for _, raw := range lines[1:end] { + for i := 1; i < end; { + raw := lines[i] trim := strings.TrimSpace(raw) if trim == "" || strings.HasPrefix(trim, "#") { + i++ continue } idx := strings.Index(raw, ":") if idx < 0 { + i++ continue } key := strings.TrimSpace(raw[:idx]) value := strings.TrimSpace(raw[idx+1:]) - fields[key] = parseValue(value) + // A YAML block scalar (`key: >` / `key: |`) folds the following + // more-indented lines into the value; without this they would be dropped + // and the field left as a bare ">" or "|". + if isBlockScalarIndicator(value) { + folded, next := readBlockScalar(lines, i+1, end, value) + fields[key] = folded + i = next + continue + } + fields[key] = parseValue(stripInlineComment(value)) + i++ } body := strings.Join(lines[end+1:], "\n") return Frontmatter{Fields: fields, Body: strings.TrimLeft(body, "\n")} } +// stripInlineComment removes a trailing "# comment" from an unquoted scalar. Per +// YAML, a '#' begins a comment only when it is outside quotes and preceded by +// whitespace, so `inclusion: always # standard` yields "always", while a '#' +// inside quotes (or with no leading space) stays part of the value. +func stripInlineComment(v string) string { + inSingle, inDouble := false, false + for i := 0; i < len(v); i++ { + switch c := v[i]; { + case c == '\'' && !inDouble: + inSingle = !inSingle + case c == '"' && !inSingle: + inDouble = !inDouble + case c == '#' && !inSingle && !inDouble && i > 0 && (v[i-1] == ' ' || v[i-1] == '\t'): + return strings.TrimRight(v[:i], " \t") + } + } + return v +} + +// isBlockScalarIndicator reports whether a value is a lone block-scalar header: +// '|' or '>' optionally followed by chomping/indentation indicators (-, +, digit) +// and an inline comment. `> some text` (letters after the indicator) is not one. +func isBlockScalarIndicator(v string) bool { + if v == "" || (v[0] != '|' && v[0] != '>') { + return false + } + rest := strings.TrimSpace(stripInlineComment(v[1:])) + for _, c := range rest { + if c != '-' && c != '+' && (c < '0' || c > '9') { + return false + } + } + return true +} + +// readBlockScalar folds the block-scalar body starting at lines[start] up to end. +// A folded scalar ('>') joins lines with spaces; a literal one ('|') keeps +// newlines. It returns the value and the index of the first line after the block. +func readBlockScalar(lines []string, start, end int, indicator string) (string, int) { + folded := indicator[0] == '>' + var content []string + baseIndent := -1 + i := start + for ; i < end; i++ { + line := lines[i] + if strings.TrimSpace(line) == "" { + content = append(content, "") + continue + } + indent := len(line) - len(strings.TrimLeft(line, " \t")) + if baseIndent == -1 { + baseIndent = indent + } + if indent < baseIndent { + break // dedented to a sibling key: the block ends here + } + content = append(content, line[baseIndent:]) + } + for len(content) > 0 && content[len(content)-1] == "" { + content = content[:len(content)-1] + } + if folded { + return strings.Join(strings.Fields(strings.Join(content, " ")), " "), i + } + return strings.Join(content, "\n"), i +} + func parseValue(raw string) any { raw = strings.TrimSpace(raw) if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { diff --git a/internal/frontmatter/frontmatter_test.go b/internal/frontmatter/frontmatter_test.go index 2cbe5c6..f2d5b5c 100644 --- a/internal/frontmatter/frontmatter_test.go +++ b/internal/frontmatter/frontmatter_test.go @@ -109,3 +109,67 @@ func TestCSVRespectsQuotes(t *testing.T) { t.Errorf("CSV: got %v want %v", got, want) } } + +func TestParseCRLFFrontmatter(t *testing.T) { + in := "---\r\ninclusion: always\r\nname: api-design\r\n---\r\n\r\n# Body\r\n" + fm := Parse(in) + if got := fm.AsString("inclusion", ""); got != "always" { + t.Errorf("CRLF inclusion = %q, want always", got) + } + if got := fm.AsString("name", ""); got != "api-design" { + t.Errorf("CRLF name = %q, want api-design", got) + } +} + +func TestParseBOMFrontmatter(t *testing.T) { + bom := string(rune(0xFEFF)) + in := bom + "---\ninclusion: always\n---\n# Body\n" + fm := Parse(in) + if got := fm.AsString("inclusion", ""); got != "always" { + t.Errorf("BOM-prefixed frontmatter did not parse: inclusion = %q", got) + } +} + +func TestParseStripsInlineComment(t *testing.T) { + in := "---\ninclusion: always # standard mode\nname: thing\n---\n" + fm := Parse(in) + if got := fm.AsString("inclusion", ""); got != "always" { + t.Errorf("inline comment not stripped: inclusion = %q", got) + } +} + +func TestParseHashInsideQuotesKept(t *testing.T) { + in := "---\ndescription: \"has a # hash\"\n---\n" + fm := Parse(in) + if got := fm.AsString("description", ""); got != "has a # hash" { + t.Errorf("quoted hash mangled: description = %q", got) + } +} + +func TestParseFoldedBlockScalar(t *testing.T) { + in := "---\ndescription: >\n first line\n second line\nname: thing\n---\n" + fm := Parse(in) + if got := fm.AsString("description", ""); got != "first line second line" { + t.Errorf("folded block scalar = %q, want %q", got, "first line second line") + } + if got := fm.AsString("name", ""); got != "thing" { + t.Errorf("field after block scalar lost: name = %q", got) + } +} + +func TestParseLiteralBlockScalar(t *testing.T) { + in := "---\ndescription: |\n line one\n line two\n---\n" + fm := Parse(in) + if got := fm.AsString("description", ""); got != "line one\nline two" { + t.Errorf("literal block scalar = %q, want %q", got, "line one\nline two") + } +} + +func TestParseEscapedQuotesInArray(t *testing.T) { + in := "---\ntools: [\"a, b\", c]\n---\n" + fm := Parse(in) + got := fm.AsStringSlice("tools") + if len(got) != 2 || got[0] != "a, b" || got[1] != "c" { + t.Errorf("array with embedded comma mis-split: %v", got) + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index ceb934a..10681f5 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -14,6 +14,8 @@ import ( "os" "path/filepath" "time" + + "github.com/protonspy/csdd/internal/textutil" ) // Manifest is the on-disk record at .claude/.csdd-manifest.json. Files maps a @@ -68,12 +70,41 @@ func (m *Manifest) Save(path, version string, now time.Time) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - return os.WriteFile(path, data, 0o644) + return writeFileAtomic(path, data, 0o644) +} + +// writeFileAtomic writes data to a sibling temp file and renames it over path, +// so a crash or concurrent reader never observes a half-written manifest. The +// temp file lives in the same directory as path so the rename stays on one +// filesystem (a cross-device rename would fail). +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op after a successful rename + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) } // Hash is the canonical content hash used throughout the manifest and the update // reconciler. The "sha256:" prefix makes the algorithm explicit on disk. +// Content is line-ending normalized first so a file's hash is identical whether +// git checked it out as LF or CRLF — otherwise every managed file on a Windows +// autocrlf clone would be misread as user-edited by `csdd update`. func Hash(content string) string { - sum := sha256.Sum256([]byte(content)) + sum := sha256.Sum256([]byte(textutil.NormalizeNewlines(content))) return "sha256:" + hex.EncodeToString(sum[:]) } diff --git a/internal/session/reports.go b/internal/session/reports.go index 6e49a2c..7a8fe93 100644 --- a/internal/session/reports.go +++ b/internal/session/reports.go @@ -529,7 +529,7 @@ func parseLcov(root, path string) *Coverage { if err != nil { return nil } - defer f.Close() + defer func() { _ = f.Close() }() cov := &Coverage{Format: "lcov", Source: rel(root, path), Files: []FileCoverage{}} var cur *FileCoverage @@ -648,7 +648,7 @@ func parseGoCover(root, path string) *Coverage { if err != nil { return nil } - defer f.Close() + defer func() { _ = f.Close() }() cov := &Coverage{Format: "gocover", Source: rel(root, path), Files: []FileCoverage{}} perFile := map[string]*FileCoverage{} diff --git a/internal/session/session.go b/internal/session/session.go index a28a1ad..cc6bfa5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -160,12 +160,9 @@ func LoadSpecDetail(root, feature string) (SpecDetail, error) { if fi, err := os.Stat(specDir); err != nil || !fi.IsDir() { return SpecDetail{}, fmt.Errorf("spec not found: %s", feature) } - d := SpecDetail{SpecCard: buildCard(specsDir, feature)} - if data, err := os.ReadFile(filepath.Join(specDir, "tasks.md")); err == nil { - d.Phases, _ = ParseTasks(string(data)) - } - s, _ := loadSpec(specDir) - for _, is := range validationIssues(specDir, s) { + card, phases, issues, _ := buildCardFull(specsDir, feature) + d := SpecDetail{SpecCard: card, Phases: phases} + for _, is := range issues { d.IssueList = append(d.IssueList, ValidationIssue{File: is.File, Line: is.Line, Msg: is.Msg}) } d.Phases = orEmpty(d.Phases) @@ -176,6 +173,16 @@ func LoadSpecDetail(root, feature string) (SpecDetail, error) { } func buildCard(specsDir, feature string) SpecCard { + card, _, _, _ := buildCardFull(specsDir, feature) + return card +} + +// buildCardFull assembles a spec card and also returns the parsed task phases, +// validation issues, and spec.json it computed along the way. LoadSpecDetail +// reuses these instead of re-reading tasks.md, re-loading spec.json, and +// re-running the validator a second time — the dashboard polls this path every +// ~800ms, and on WSL's /mnt/c each avoided file read crosses the 9p boundary. +func buildCardFull(specsDir, feature string) (SpecCard, []TaskPhase, []validator.Issue, specJSON) { specDir := filepath.Join(specsDir, feature) card := SpecCard{Feature: feature, Approvals: map[string]Approval{}} s, ok := loadSpec(specDir) @@ -203,13 +210,16 @@ func buildCard(specsDir, feature string) SpecCard { } sort.Strings(card.Artifacts) } + var phases []TaskPhase if data, err := os.ReadFile(filepath.Join(specDir, "tasks.md")); err == nil { - _, stats := ParseTasks(string(data)) + var stats TaskStats + phases, stats = ParseTasks(string(data)) card.Tasks = stats } - card.Issues = len(validationIssues(specDir, s)) + issues := validationIssues(specDir, s) + card.Issues = len(issues) card.Artifacts = orEmpty(card.Artifacts) - return card + return card, phases, issues, s } // validationIssues runs the mechanical validator scoped to the spec's furthest diff --git a/internal/session/tasks.go b/internal/session/tasks.go index 3e6f42a..14e4856 100644 --- a/internal/session/tasks.go +++ b/internal/session/tasks.go @@ -3,20 +3,23 @@ package session import ( "regexp" "strings" + + "github.com/protonspy/csdd/internal/textutil" + "github.com/protonspy/csdd/internal/validator" ) -// Task display parser. This deliberately differs from validator.parseTasks: -// the validator is concerned with correctness (it ignores the checkbox state -// and phase grouping), while the dashboard needs the done-state, the phase a -// task lives under, and the RED/GREEN TDD marker. The base regexes mirror the -// validator's literals so the two stay behaviourally consistent. +// Task display parser. The dashboard needs more than the validator's correctness +// pass (done-state, the phase a task lives under, the RED/GREEN TDD marker), but +// it shares the validator's *canonical* task-line and annotation grammar so the +// two surfaces can never disagree on what counts as a task, boundary, or +// requirement reference. Only the display-only regexes live here. var ( - taskLineRe = regexp.MustCompile(`^(\s*)-\s+\[\s*([xX ]?)\s*\]\s+(\d+(?:\.\d+)?)\.?\s+(.*)$`) + taskLineRe = validator.TaskLineRe + reqAnnotRe = validator.ReqAnnotRe + boundaryRe = validator.BoundaryAnnotRe + dependsRe = validator.DependsAnnotRe + parallelRe = validator.ParallelRe phaseHeadRe = regexp.MustCompile(`^##\s+Phase\s+\d+\s*:?\s*(.*)$`) - reqAnnotRe = regexp.MustCompile(`_Requirements:\s*([\d,\.\s]+)_`) - boundaryRe = regexp.MustCompile(`_Boundary:\s*([A-Za-z0-9_\-<>]+)_`) - dependsRe = regexp.MustCompile(`_Depends:\s*([\d,\.\s]+)_`) - parallelRe = regexp.MustCompile(`\(P\)`) tddRe = regexp.MustCompile(`(?i)^(RED|GREEN)\b`) ) @@ -52,6 +55,10 @@ type TaskStats struct { // stats. Annotation lines (indented "_Requirements:_" etc.) following a task // line are attributed to that task. func ParseTasks(text string) ([]TaskPhase, TaskStats) { + // Normalize line endings and blank out fenced code so a "- [ ] 1. …" example + // inside a ``` block never inflates the board's Total/Done counts — matching + // exactly what the validator counts. + text = validator.MaskCodeFences(textutil.NormalizeNewlines(text)) var phases []TaskPhase var stats TaskStats curPhase := -1 diff --git a/internal/templater/templater.go b/internal/templater/templater.go index 7b28704..2e35bb7 100644 --- a/internal/templater/templater.go +++ b/internal/templater/templater.go @@ -11,6 +11,8 @@ import ( "io/fs" "strings" "text/template" + + "github.com/protonspy/csdd/internal/textutil" ) //go:embed all:templates @@ -33,7 +35,9 @@ func Render(efs fs.FS, path string, data any) (string, error) { if err := tmpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("execute template %s: %w", path, err) } - return buf.String(), nil + // Emit LF so scaffolded files are byte-deterministic regardless of whether + // git checked the embedded templates out as LF or CRLF on the build machine. + return textutil.NormalizeNewlines(buf.String()), nil } // Static returns the raw text of a template without rendering, useful for @@ -43,7 +47,7 @@ func Static(efs fs.FS, path string) (string, error) { if err != nil { return "", fmt.Errorf("read template %s: %w", path, err) } - return string(raw), nil + return textutil.NormalizeNewlines(string(raw)), nil } // RuleFiles enumerates every .claude/rules/ template, returning a @@ -66,7 +70,7 @@ func RuleFiles(efs fs.FS) (map[string]string, error) { } // Strip the ".tmpl" suffix when emitting on disk. name := strings.TrimSuffix(e.Name(), ".tmpl") - out[name] = string(raw) + out[name] = textutil.NormalizeNewlines(string(raw)) } return out, nil } @@ -89,7 +93,7 @@ func staticTree(efs fs.FS, dir string) (map[string]string, error) { } rel := strings.TrimPrefix(p, dir+"/") rel = strings.TrimSuffix(rel, ".tmpl") - out[rel] = string(raw) + out[rel] = textutil.NormalizeNewlines(string(raw)) return nil }) if err != nil { @@ -144,7 +148,7 @@ func WorkflowTemplateFiles(efs fs.FS) (map[string]string, error) { if name == "spec.json" { name = "init.json" } - out["specs/"+name] = string(raw) + out["specs/"+name] = textutil.NormalizeNewlines(string(raw)) } steeringEntries, err := fs.ReadDir(efs, "templates/steering") @@ -168,14 +172,14 @@ func WorkflowTemplateFiles(efs fs.FS) (map[string]string, error) { if err != nil { return nil, err } - out["steering/"+name] = string(raw) + out["steering/"+name] = textutil.NormalizeNewlines(string(raw)) } customRef, err := fs.ReadFile(efs, "templates/steering-custom/custom.md.tmpl") if err != nil { return nil, err } - out["steering-custom/custom.md"] = string(customRef) + out["steering-custom/custom.md"] = textutil.NormalizeNewlines(string(customRef)) return out, nil } diff --git a/internal/templater/templates/agent/agent.md.tmpl b/internal/templater/templates/agent/agent.md.tmpl index 6bf49c2..771eea1 100644 --- a/internal/templater/templates/agent/agent.md.tmpl +++ b/internal/templater/templates/agent/agent.md.tmpl @@ -1,35 +1,38 @@ ---- -name: {{.Name}} -description: {{.Description}} -tools: {{.Tools}} -{{- if .Model}} -model: {{.Model}} -{{- end}} -{{- if .Effort}} -effort: {{.Effort}} -{{- end}} ---- - -# {{.Title}} - -## Role -[One paragraph: what this sub-agent does and the boundary it respects.] - -## Inputs -- [What the orchestrator passes in.] - -## Operating Procedure -1. [Step 1] -2. [Step 2] -3. [Step 3] - -## Output Format -[Exact shape the orchestrator expects back. Templates outperform prose.] - -## Tool Scope and Least Privilege -- Allowed tools: {{.Tools}} -- Read-only outside this scope unless escalated by the orchestrator. - -## Done Criteria -- [ ] [Concrete output produced] -- [ ] [Self-check passed] +--- +name: {{.Name}} +description: {{.Description}} +tools: {{.Tools}} +{{- if .Model}} +model: {{.Model}} +{{- end}} +{{- if .Effort}} +effort: {{.Effort}} +{{- end}} +{{- if .Color}} +color: {{.Color}} +{{- end}} +--- + +# {{.Title}} + +## Role +[One paragraph: what this sub-agent does and the boundary it respects.] + +## Inputs +- [What the orchestrator passes in.] + +## Operating Procedure +1. [Step 1] +2. [Step 2] +3. [Step 3] + +## Output Format +[Exact shape the orchestrator expects back. Templates outperform prose.] + +## Tool Scope and Least Privilege +- Allowed tools: {{.Tools}} +- Read-only outside this scope unless escalated by the orchestrator. + +## Done Criteria +- [ ] [Concrete output produced] +- [ ] [Self-check passed] diff --git a/internal/templater/templates/agents/code-reviewer.md.tmpl b/internal/templater/templates/agents/code-reviewer.md.tmpl index d98be50..2e81d9f 100644 --- a/internal/templater/templates/agents/code-reviewer.md.tmpl +++ b/internal/templater/templates/agents/code-reviewer.md.tmpl @@ -2,6 +2,7 @@ name: code-reviewer description: Read-only adversarial reviewer for a completed SDD/TDD task or a diff before opening a PR. Use after each task slice or before raising a pull request. tools: Read, Grep, Glob +color: red --- You are a strict but pragmatic code reviewer. You review; you do not rewrite. diff --git a/internal/templater/templates/agents/implementer.md.tmpl b/internal/templater/templates/agents/implementer.md.tmpl index 878efa0..9cc7312 100644 --- a/internal/templater/templates/agents/implementer.md.tmpl +++ b/internal/templater/templates/agents/implementer.md.tmpl @@ -2,6 +2,10 @@ name: implementer description: Implements a single task from specs/*/tasks.md inside its approved design.md boundary, following the spec's development_flow (tdd test-first by default; unit tests-after; tdd-e2e adds end-to-end) — runs the verification gate, records evidence, marks the task done, updates notes, and reports. Language-agnostic; specialize per stack via steering and skills. tools: Read, Grep, Glob, Edit, Write, Bash +skills: + - tdd-cycle + - verify-change +color: green --- You implement **one task at a time**, following the spec's `development_flow`, and @@ -86,6 +90,10 @@ variants only add the stack-specific knowledge. ## Gotchas +- When the `csdd` MCP server is connected, prefer the `csdd_spec_*` tools (e.g. + `csdd_spec_test_report`, `csdd_spec_diff_report`) over the `npx -y @protonspy/csdd` + forms shown above — identical gates and exit codes, typed arguments. Fall back to + `npx` only when the server is not connected. - If no test framework exists, say so explicitly and stop — do not silently skip the test (RED under `tdd`/`tdd-e2e`, the same-task unit test under `unit`). - A passing suite is not proof of coverage; make sure the new behavior is exercised. diff --git a/internal/templater/templates/agents/security-reviewer.md.tmpl b/internal/templater/templates/agents/security-reviewer.md.tmpl index 24d69eb..a8b8f05 100644 --- a/internal/templater/templates/agents/security-reviewer.md.tmpl +++ b/internal/templater/templates/agents/security-reviewer.md.tmpl @@ -2,6 +2,7 @@ name: security-reviewer description: Read-only security review of the current diff. Use whenever a change touches authentication, authorization, secrets, input handling, logging, or data exposure. tools: Read, Grep, Glob +color: orange --- You perform a focused security review. You have no write access by design. diff --git a/internal/templater/templates/agents/test-designer.md.tmpl b/internal/templater/templates/agents/test-designer.md.tmpl index b7f7b7a..a059da3 100644 --- a/internal/templater/templates/agents/test-designer.md.tmpl +++ b/internal/templater/templates/agents/test-designer.md.tmpl @@ -2,6 +2,7 @@ name: test-designer description: Read-only test-gap analyst. Use before implementing a task (to enumerate the tests it needs) or after (to find missing cases) — identifies edge cases, error paths, and regressions that lack coverage. tools: Read, Grep, Glob +color: cyan --- You design tests; you do not write production code and you do not implement the tests yourself unless explicitly asked. Your output is a precise, ordered list of test cases. diff --git a/internal/templater/templates/agents/wf-development.md.tmpl b/internal/templater/templates/agents/wf-development.md.tmpl index 199db2e..b868dfd 100644 --- a/internal/templater/templates/agents/wf-development.md.tmpl +++ b/internal/templater/templates/agents/wf-development.md.tmpl @@ -1,7 +1,9 @@ --- name: wf-development description: Downstream development workflow lead (wf:development). Orchestrates architecture → epics/stories → readiness → sprint → TDD implementation → review → retrospective on top of csdd's spec gates. Use after wf:product/discovery has produced an approved spec. -tools: Read, Grep, Glob, Write, Edit, Bash, Skill +tools: Read, Grep, Glob, Write, Edit, Bash, Skill, Agent +effort: high +color: blue --- You are the Development Lead — the downstream half of the workflow pair @@ -13,6 +15,14 @@ You orchestrate. The new skills add the BMAD planning layer (architecture, epics/stories, readiness, sprint, retrospective); the *implementation and review* reuse csdd's existing, validated machinery rather than reinventing it. +**Run this as the main session agent** — launch it with `claude --agent wf-development` +or set `"agent": "wf-development"` in `.claude/settings.json`; do not invoke it as a +spawned sub-agent. Only the main session agent gets slash commands (`/clear`), +interactive turns to pause at each gate for the human's approval, and the ability to +spawn the fresh-context sub-agents (`implementer`, `code-reviewer`, `security-reviewer`, +`test-designer`) this workflow drives. A spawned sub-agent runs straight to completion +with no gate stops — exactly what this workflow forbids. + ## Reuse, don't duplicate This workflow stands on what csdd already ships: @@ -20,8 +30,8 @@ This workflow stands on what csdd already ships: - **Spec gates:** `npx -y @protonspy/csdd spec generate|validate|approve` for design and tasks. - **Implementation:** the `tdd-cycle` skill — one red→green→refactor per task. - **Verification:** the `verify-change` skill for the evidence block. -- **Review:** the `code-reviewer` and `security-reviewer` sub-agents and the - `pr-review` skill. +- **Review:** the `test-designer` sub-agent for test-gap analysis, the + `code-reviewer` and `security-reviewer` sub-agents, and the `pr-review` skill. - **Commit:** the `/csdd-commit` command and the pre-push test gate. The `dev-*` skills below fill only the gaps BMAD covers that csdd did not. @@ -52,14 +62,26 @@ The `dev-*` skills below fill only the gaps BMAD covers that csdd did not. kebab-case (`feat/` feature, `fix/` bugfix, `chore/` small adjustment; also `refactor/` `docs/` `test/` `perf/`). Then run `tdd-cycle` one task at a time, updating `sprint-status.yaml` as tasks move. Never batch tasks "to save time". + **Parallelize across boundaries.** Tasks marked `(P)` in *different* + `_Boundary:_` groups have no ordering between them: dispatch one `implementer` + sub-agent per such task **concurrently** — each still owns exactly one task — + spawning each with worktree isolation (`isolation: worktree`) so simultaneous + writes don't collide, then integrating them back. Honor every `_Depends:_` + before starting a task, and keep + same-boundary tasks sequential. The rule is "one task per `implementer`", not + "one `implementer` at a time". 4. **Review every slice** with `code-reviewer` (and `security-reviewer` when auth - / secrets / input handling is touched). Resolve blockers before the next task. + / secrets / input handling is touched); use `test-designer` to enumerate missing + cases before or after a slice. Resolve blockers before the next task. 5. **Close the epic** with `dev-retrospective` and feed durable lessons back into steering. Once the feature's PR ships, run `/clear` before the next feature so context does not accumulate across specs. ## Gotchas +- When the `csdd` MCP server is connected, drive the gates with the `csdd_*` tools + (typed arguments, identical exit codes) and fall back to the `npx -y @protonspy/csdd` + forms shown above only when it is not — the phase gates are the same either way. - You are downstream only. If a requirement is wrong or missing, do not patch it in code — route it back to discovery; the PRD and EARS requirements are the source of truth. diff --git a/internal/templater/templates/agents/wf-product-discovery.md.tmpl b/internal/templater/templates/agents/wf-product-discovery.md.tmpl index 881f667..3b6856c 100644 --- a/internal/templater/templates/agents/wf-product-discovery.md.tmpl +++ b/internal/templater/templates/agents/wf-product-discovery.md.tmpl @@ -2,6 +2,8 @@ name: wf-product-discovery description: Upstream product-discovery workflow lead (wf:product/discovery). Orchestrates brief → research → PRFAQ → PRD → UX, then hands a validated PRD into csdd steering + a spec. Use to turn a raw idea into a decision-ready product definition. tools: Read, Grep, Glob, Write, Edit, Bash, Skill +effort: high +color: purple --- You are the Product Discovery Lead — the upstream half of the workflow pair @@ -13,6 +15,13 @@ You orchestrate; the member skills do the focused work. You decide what runs next based on how much is already known, you enforce the gates between phases, and you keep every artifact traceable back to a real user problem. +**Run this as the main session agent** — launch it with +`claude --agent wf-product-discovery` or set `"agent": "wf-product-discovery"` in +`.claude/settings.json`; do not invoke it as a spawned sub-agent. The phase gates +below require the human to confirm before advancing and use slash commands, which only +the main session agent can do — a spawned sub-agent would run straight through every +phase with no stops. + ## Workflow map Each phase is a skill. Phases are optional except the PRD and the handoff — run diff --git a/internal/templater/templates/hooks/block-destructive.sh.tmpl b/internal/templater/templates/hooks/block-destructive.sh.tmpl index 734f6f2..4361b69 100644 --- a/internal/templater/templates/hooks/block-destructive.sh.tmpl +++ b/internal/templater/templates/hooks/block-destructive.sh.tmpl @@ -13,7 +13,15 @@ if command -v jq >/dev/null 2>&1; then elif command -v python3 >/dev/null 2>&1; then cmd="$(printf '%s' "$input" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' 2>/dev/null || true)" else - cmd="$input" + # No jq/python3: best-effort extract just the "command" string with sed rather + # than scanning the whole payload (matching the whole payload would let the + # denylist trip on file paths or a tool description that merely mentions, say, + # "rm -rf"). Heuristic only — it stops at the first embedded quote, so a + # command containing an escaped quote is matched by its (leading) prefix. If + # extraction yields nothing, fall back to the whole payload so we fail safe + # (may over-block) rather than letting a destructive command slip through. + cmd="$(printf '%s' "$input" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)" + [ -n "$cmd" ] || cmd="$input" fi # Denylist of destructive patterns. Conservative on purpose: these need a human. diff --git a/internal/templater/templates/hooks/format-after-edit.sh.tmpl b/internal/templater/templates/hooks/format-after-edit.sh.tmpl index 94802be..2b670d9 100644 --- a/internal/templater/templates/hooks/format-after-edit.sh.tmpl +++ b/internal/templater/templates/hooks/format-after-edit.sh.tmpl @@ -1,8 +1,9 @@ #!/bin/bash -# PostToolUse(Edit|Write|MultiEdit) hook: best-effort format the edited file. +# PostToolUse(Edit|Write|NotebookEdit) hook: best-effort format the edited file. # Reads tool_input.file_path from the JSON on stdin. Always exits 0 — formatting -# is a convenience, never a gate. Unknown extensions and missing formatters are -# silently skipped. +# is a convenience, never a gate. Unknown extensions, missing formatters, AND +# formatters that fail (e.g. on a syntactically-broken file mid-edit) are all +# silently tolerated: each branch ends in `|| true` so `set -e` never aborts. set -euo pipefail input="$(cat)" @@ -17,11 +18,11 @@ fi [ -n "$file" ] && [ -f "$file" ] || exit 0 case "$file" in - *.go) command -v gofmt >/dev/null 2>&1 && gofmt -w "$file" ;; - *.rs) command -v rustfmt >/dev/null 2>&1 && rustfmt "$file" ;; - *.py) command -v black >/dev/null 2>&1 && black -q "$file" ;; + *.go) command -v gofmt >/dev/null 2>&1 && gofmt -w "$file" || true ;; + *.rs) command -v rustfmt >/dev/null 2>&1 && rustfmt "$file" || true ;; + *.py) command -v black >/dev/null 2>&1 && black -q "$file" || true ;; *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) - command -v prettier >/dev/null 2>&1 && prettier --write "$file" ;; + command -v prettier >/dev/null 2>&1 && prettier --write "$file" || true ;; esac exit 0 diff --git a/internal/templater/templates/hooks/test-before-stop.sh.tmpl b/internal/templater/templates/hooks/test-before-stop.sh.tmpl index 007828c..c5832b9 100644 --- a/internal/templater/templates/hooks/test-before-stop.sh.tmpl +++ b/internal/templater/templates/hooks/test-before-stop.sh.tmpl @@ -1,31 +1,26 @@ #!/bin/bash -# Stop hook: remind the agent to verify before claiming completion. +# Stop hook: nudge the agent to verify before it claims completion. # -# By default this is ADVISORY (exits 0) — running the full suite on every Stop -# is slow and noisy. It prints the verification checklist so the reminder lands -# in context. To make it a hard gate, uncomment the block below to run your -# project's checks and `exit 2` on failure (exit 2 tells Claude Code to keep -# working rather than stop). -set -euo pipefail - -echo "Before reporting done, run the verify-change skill: tests, lint, typecheck, build — and paste the real output." >&2 - -# Spec-boundary context hygiene: when the spec you're working on has all its -# tasks checked, nudge the operator to /clear before the next feature so context -# does not accumulate across specs. Claude cannot run /clear itself — this is a -# reminder to the human. Keyed off the most recently modified specs/*/tasks.md. -specs_dir="${CLAUDE_PROJECT_DIR:-$PWD}/specs" -latest_tasks=$(ls -t "$specs_dir"/*/tasks.md 2>/dev/null | head -n1 || true) -if [ -n "$latest_tasks" ]; then - total=$(grep -cE '^[[:space:]]*- \[[ xX]\]' "$latest_tasks" 2>/dev/null || true) - open=$(grep -cE '^[[:space:]]*- \[ \]' "$latest_tasks" 2>/dev/null || true) - if [ "${total:-0}" -gt 0 ] && [ "${open:-0}" -eq 0 ]; then - feature=$(basename "$(dirname "$latest_tasks")") - echo "Spec '$feature' has all tasks checked — once its PR ships, run /clear to reset context before the next feature." >&2 - fi -fi +# Claude Code Stop-hook contract: +# * exit 0 with NO stdout -> allow the stop (silent). +# * print {"decision":"block","reason":"..."} to STDOUT and exit 0 +# -> block the stop; "reason" is fed back to Claude as its next turn. +# * exit 2 with a message on STDERR -> also blocks the stop (hard gate). +# A plain `echo ... >&2; exit 0` does NOT reach Claude for a Stop hook, so this +# script emits the JSON decision form. To avoid an infinite stop-loop it fires +# ONE-SHOT: the first Stop blocks with the reminder, the next Stop allows the +# stop. Two independent loop-guards make that safe: +# 1. stdin's `stop_hook_active` is true when Claude is already continuing +# because a previous Stop hook blocked -> we never block again. +# 2. a per-session sentinel marker records that we already nudged this round. +set -uo pipefail + +input="$(cat)" # --- Hard gate (opt-in): uncomment and adapt to your project --------------- +# Runs your checks on every Stop and blocks with exit 2 (stderr is fed back to +# Claude) until they pass. Enable this only if you want a strict gate; it +# supersedes the advisory one-shot nudge below. # if [ -f Makefile ] && grep -qE '^(test|verify):' Makefile; then # make verify || make test || { echo "verification failed — not done yet" >&2; exit 2; } # elif [ -f package.json ]; then @@ -35,4 +30,70 @@ fi # fi # --------------------------------------------------------------------------- +# Parse the two fields we care about from the stdin JSON (jq -> python3 -> crude +# grep/sed fallback so the hook works with none of them installed). +stop_active=false +session_id="" +if command -v jq >/dev/null 2>&1; then + stop_active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)" + session_id="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true)" +elif command -v python3 >/dev/null 2>&1; then + stop_active="$(printf '%s' "$input" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("stop_hook_active",False)).lower())' 2>/dev/null || echo false)" + session_id="$(printf '%s' "$input" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("session_id",""))' 2>/dev/null || true)" +else + printf '%s' "$input" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' && stop_active=true + session_id="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)" +fi + +# Per-session marker kept in the temp dir (not the repo) so it auto-cleans and +# never gets committed. +marker="${TMPDIR:-/tmp}/csdd-stop-nudged-${session_id:-default}" + +# Loop-guard 1: Claude is already continuing from a previous Stop-hook block. +# Never block again; clear any stale marker. +if [ "$stop_active" = "true" ]; then + rm -f "$marker" 2>/dev/null || true + exit 0 +fi + +# Loop-guard 2: we already nudged on the previous Stop -> allow the stop now +# and clear the marker. +if [ -f "$marker" ]; then + rm -f "$marker" 2>/dev/null || true + exit 0 +fi + +# Build the reminder that will be fed back to Claude. +reason="Before reporting done, run the verify-change skill: tests, lint, typecheck, build — and paste the real output." + +# Spec-boundary context hygiene: if the most recently modified specs/*/tasks.md +# has every task checked, fold in a /clear reminder (Claude cannot run /clear +# itself, but it will surface this to the operator). Keyed off the newest +# specs/*/tasks.md. +specs_dir="${CLAUDE_PROJECT_DIR:-$PWD}/specs" +latest_tasks="$(ls -t "$specs_dir"/*/tasks.md 2>/dev/null | head -n1 || true)" +if [ -n "$latest_tasks" ]; then + total="$(grep -cE '^[[:space:]]*- \[[ xX]\]' "$latest_tasks" 2>/dev/null || true)" + open="$(grep -cE '^[[:space:]]*- \[ \]' "$latest_tasks" 2>/dev/null || true)" + if [ "${total:-0}" -gt 0 ] && [ "${open:-0}" -eq 0 ]; then + feature="$(basename "$(dirname "$latest_tasks")")" + reason="$reason Also: spec '$feature' has all tasks checked — once its PR ships, remind the operator to run /clear before the next feature." + fi +fi + +# Record the marker BEFORE emitting so that even a crash mid-emit lets the next +# Stop through, then emit the one-shot block decision on stdout and exit 0. +touch "$marker" 2>/dev/null || true + +if command -v jq >/dev/null 2>&1; then + jq -n --arg r "$reason" '{decision:"block", reason:$r}' +elif command -v python3 >/dev/null 2>&1; then + printf '%s' "$reason" | python3 -c 'import sys,json;print(json.dumps({"decision":"block","reason":sys.stdin.read()}))' +else + # No jq/python3: the reason built above contains no " or \ characters, so this + # hand-rolled JSON is safe. (A spec directory name containing a double quote + # could break it, but those are not valid feature names.) + printf '{"decision":"block","reason":"%s"}\n' "$reason" +fi + exit 0 diff --git a/internal/templater/templates/root/CLAUDE.md.tmpl b/internal/templater/templates/root/CLAUDE.md.tmpl index 6bf3b0d..c3607ac 100644 --- a/internal/templater/templates/root/CLAUDE.md.tmpl +++ b/internal/templater/templates/root/CLAUDE.md.tmpl @@ -1,250 +1,250 @@ -# Project Entry Point - -This repository follows a Spec-Driven Development (SDD) + Test-Driven -Development (TDD) workflow, native to **Claude Code**. Steering, specs, skills, -and custom sub-agents are managed by the `csdd` CLI — or, preferably, its MCP -tools (see *Driving csdd* below). - -**This file is the contract *and* the operational reference.** Everything an -agent needs to drive `csdd` — the gates, command surface, and phrasing rules — -lives here. Read it before you create, generate, approve, or edit *any* steering -file, spec, skill, or agent. For the full CLI surface, run -`npx @protonspy/csdd --help`. - -## STOP — hard rules, enforced not advisory - -Breaking one of these corrupts the contract layer that every human review and -every gate depends on: - -1. **`csdd` is the only sanctioned author of managed files.** Create and change - steering, specs, skills, and agents **only** through the `csdd_*` MCP tools or - the `csdd` CLI — never by writing the files directly. -2. **Never hand-edit `spec.json`.** It records phase approvals and - `ready_for_implementation`. Writing to it directly — even to "unblock" - yourself — bypasses the human gate and is a process violation. Phases are - crossed *only* with `npx @protonspy/csdd spec approve --phase `, which a - human authorizes. -3. **Never hand-edit frontmatter, task annotations, or `.mcp.json`.** If a - generated file is wrong, regenerate it from the template and edit the *body*, - then re-validate — do not patch the machine-managed parts by hand. -4. **Never skip a phase gate.** `requirements → design → tasks → implementation` - is strictly ordered; you cannot generate a phase until the prior one is - human-approved. `--force` is allowed **only** when the user explicitly - authorizes a fast-track / Quick Plan. -5. **When a rule blocks you, stop and surface it** — report the blocked item to - the human. Do not route around the gate. - -## Driving csdd — prefer the MCP tools for the dev flow - -`npx @protonspy/csdd init` registers a **`csdd` MCP server** in `.mcp.json`. When it is -connected, drive the development flow with its **`csdd_*` tools** instead of -shelling out: - -| Resource | Tools | CLI fallback | -|---|---|---| -| steering | `csdd_steering_*` | `npx @protonspy/csdd steering …` | -| spec | `csdd_spec_*` | `npx @protonspy/csdd spec …` | -| skill | `csdd_skill_*` | `npx @protonspy/csdd skill …` | -| agent | `csdd_agent_*` | `npx @protonspy/csdd agent …` | - -Typed parameters (the `artifact`, `phase`, and `inclusion` enums, required -fields) stop you from passing invalid values, and the server builds the exact -argv. The **phase gates, validators, and exit codes are identical** either way. -Use the **CLI** when the MCP server isn't connected, and always for **setup and -management** — `init`, `copy`, `mcp`, and `export` are intentionally *not* tools. - -## Mental model — read this first - -Two distinctions matter more than anything else: - -- **Specification vs Design.** *Specification* is the contract: - `requirements.md`, the File Structure Plan inside `design.md`, and the - `_Boundary:_`/`_Depends:_` annotations on `tasks.md`. **Humans review and - approve this.** *Design* is the implementation space *inside* that contract — - components, internals, sequencing. **You are free here once the specification - is approved.** -- **Phase gates.** Every spec advances `requirements → design → tasks → - implementation`. You cannot generate a phase until the prior one is - human-approved (recorded in `spec.json`). The CLI enforces this mechanically; - `--force` is for explicitly authorized Quick Plans only. - -## Working discipline — how you write inside the gates - -The gates decide *what* to build and *when*; these decide *how* you write it. -They bias toward caution over speed — use judgment on genuinely trivial changes. - -- **Think before you code.** Don't assume, and don't hide confusion. State your - assumptions; when a requirement reads two ways, surface both instead of - silently choosing one; when a simpler path exists, say so. If something is - unclear, stop and name it — the STOP reflex above, applied to design and not - just to gates. -- **Simplicity first.** Write the least code that satisfies the task and makes - its tests pass — nothing speculative. No unrequested features, no abstraction - for single-use code, no configurability nobody asked for, no error handling - for impossible states. If 200 lines could be 50, rewrite it. This is the - *Design* freedom above, spent frugally. -- **Stay surgical.** Touch only what the task needs. Don't reformat, "improve" - adjacent code, or refactor what isn't broken; match the surrounding style even - when you'd write it differently. Remove only the imports and symbols your own - change orphaned — flag pre-existing dead code, don't delete it. Every changed - line should trace to a requirement the task carries. -- **Drive to a verifiable goal.** Turn the task into a checkable outcome before - writing code — "add validation" becomes "a failing test for invalid input, - then make it pass". That is the red→green loop the cycle already mandates: - sharp success criteria let you loop to green on your own; vague ones ("make it - work") force round-trips. - -## Command cheat sheet - -Names are **kebab-case** (`api-conventions`, `photo-albums`, `code-reviewer`); -the CLI rejects anything else. Global flags on every command: `--root PATH` -(override workspace), `--force` (override safety checks), `-h`/`--help`, -`-v`/`--version`. Exit codes: `0` OK, `1` user error, `2` validation failure. - -```bash -# bootstrap (one time per repo) -npx @protonspy/csdd init [--with-baseline] [--exclude agents,skills,…] [--no-mcp] - -# copy one shipped artifact into the workspace (pairs with init --exclude) -npx @protonspy/csdd copy skills/dev-architecture # or agents/…, rules/…, commands/…, hooks/…, templates/…, steering/… - -# steering (project memory) -npx @protonspy/csdd steering init -npx @protonspy/csdd steering create NAME --inclusion {always|fileMatch|manual|auto} \ - [--pattern GLOB]... [--description TEXT] -npx @protonspy/csdd steering {list,show,delete,validate} [NAME] - -# specs — generate → validate → human approves, one phase at a time -npx @protonspy/csdd spec init FEATURE -npx @protonspy/csdd spec generate FEATURE --artifact {requirements|design|tasks|research|bugfix} [--force] -npx @protonspy/csdd spec approve FEATURE --phase {requirements|design|tasks} [--force] -npx @protonspy/csdd spec status FEATURE # show + validate in one shot -npx @protonspy/csdd spec {list,show,validate,delete} FEATURE - -# skills — executable workflow bundles -npx @protonspy/csdd skill create NAME --description TEXT -npx @protonspy/csdd skill {add-reference,add-script,add-asset} SKILL FILE -npx @protonspy/csdd skill {list,show,validate,delete} NAME - -# custom sub-agents — least privilege -npx @protonspy/csdd agent create NAME --description TEXT [--tools TOOL]... [--model M] -npx @protonspy/csdd agent {list,show,delete} NAME - -# mcp servers (.mcp.json) -npx @protonspy/csdd mcp add NAME --command CMD [--arg A]... [--env K=V]... # stdio -npx @protonspy/csdd mcp add NAME --url URL [--type sse|http] [--env K=V]... # remote -npx @protonspy/csdd mcp {list,show,enable,disable,remove,validate} [NAME] -``` - -## The development cycle (follow it in order) - -``` -requirements ──approve──▶ design ──approve──▶ tasks ──approve──▶ implementation - (human) (human) (human) ready_for_implementation: true -``` - -1. **Start each feature on a clean context:** `/clear`, then `npx @protonspy/csdd spec init `. -2. **Generate → review → approve, one phase at a time:** `spec generate --artifact requirements` → human reviews → `spec approve --phase requirements`; then `design`, then `tasks`. The gate refuses the next `generate` until the current phase is approved. -3. `ready_for_implementation` flips to `true` only after all three phases are approved — and only csdd flips it. Do not set it yourself. -4. **Branch before the first task:** `git switch -c /` in kebab-case (`feat/`, `fix/`, `chore/`, `refactor/`, `docs/`, `test/`, `perf/`). The PreToolUse hook blocks commits on the default branch. -5. Implementation runs one task per iteration with fresh-context sub-agents (implementer → reviewer → debugger), TDD red→green per task. Stay inside the approved design boundary. -6. Commit a slice only after review is clean, then push — the `pre-push` hook runs the test gate and blocks a red push. -7. **Close each feature with a `/clear`** once its PR ships, so context does not accumulate across features. -8. Steering is updated only when a new pattern emerges that the agent could not derive from the code. - -### Variants - -- **Bugfix** — replace `requirements.md` with `bugfix.md` (`spec generate --artifact bugfix`); the phase gate is skipped. The validator requires `Current Behavior:`, `Expected Behavior:`, `Unchanged Behavior:`, and `Root Cause`. Document the root cause **before** writing any code. -- **Research** — for brownfield or novel domains add `spec generate --artifact research` before design. Every finding in `research.md` must connect to a concrete commitment in `design.md`. -- **No spec needed** — small reversible changes (typos, one-line fixes) skip the spec layer. SDD is a contract layer, not a tax. - -## EARS — the only acceptable requirement phrasing - -Every criterion in `requirements.md` **must** use EARS. Trigger keywords -(`WHEN`/`WHILE`/`IF`/`WHERE`/`SHALL`/`THE SYSTEM`) are **fixed English** — localize -only the variable parts. Use **`SHALL`**, never `should`. One behavior per -criterion. Quantify ("fast"/"robust" are not testable). - -| Pattern | Template | -|---|---| -| Event-Driven | `WHEN THEN the system SHALL ` | -| State-Driven | `WHILE THE SYSTEM SHALL ` | -| Unwanted | `IF THEN the system SHALL ` | -| Optional | `WHERE THE SYSTEM SHALL ` | -| Ubiquitous | `THE SYSTEM SHALL ` | - -IDs are numeric and unique: each `### Requirement N:` header numbers its criteria -`1.`, `2.`, … which become IDs `N.1`, `N.2`, …. - -## Tasks — annotations, boundaries, parallel safety - -`tasks.md` is not a free-form todo list. Tasks describe **capabilities and -outcomes**, never file edits. Every task carries machine-readable annotations: - -| Annotation | When required | Format | -|---|---|---| -| `_Requirements:_` | On **every leaf task** | `_Requirements: 1.1, 1.2_` — numeric IDs that exist in `requirements.md`. | -| `_Boundary:_` | On every `(P)` task; optional on others | `_Boundary: AlbumService_` — must match a `### AlbumService` component in `design.md`. | -| `_Depends:_` | When a task needs another **across boundaries** | `_Depends: 1.1, 3.2_` — IDs that exist in `tasks.md`. Same-boundary order is implicit; don't list it. | -| `(P)` | Task can run parallel to `(P)` tasks in **other** boundaries | inline marker. Two `(P)` tasks **must not** share a boundary. | - -Sizing: major tasks 3–10 sub-tasks; leaves 1–3 hours. Phase order: -`## Phase 1: Foundation → Phase 2: Core → Phase 3: Integration → Phase 4: Validation`. - -## Steering, skills, agents, MCP — the essentials - -- **Steering** (`.claude/steering/*.md`) is always-on project memory, imported into the managed block below. `inclusion:` (`always|fileMatch|manual|auto`) documents intended scope. Put organizational patterns and the *why* here; **never** secrets, file dumps, or details derivable from code. Golden rule: *if new code follows existing patterns, steering should not need updating.* -- **Skills** (`.claude/skills//`) are executable capability bundles: `SKILL.md` (≤ 500 lines / ~5k tokens) + `references/` + `assets/` + `scripts/`. Required sections: `## Goal`, `## Execution Workflow`, `## Gotchas`, `## Verification Before Reporting`, `## Completion Criteria`. Every `references/` file must be cited by name with a load trigger. Build skills from real expertise, never generic LLM output. -- **Sub-agents** (`.claude/agents/.md`) default to `Read, Grep` (least privilege). The more powerful the agent, the smaller its scope and the larger the human review must be. -- **MCP servers** (`.mcp.json`) are managed only with `npx @protonspy/csdd mcp`. Each server sets exactly one transport — `command` (stdio) **or** `url` (remote), never both. Scope narrowly, avoid `--auto-approve`, pass secrets via `--env`, never inline them. - -## Validation — what the gates catch - -`csdd * validate` runs **mechanical** checks (exit `2` on issues; fix before -continuing): - -- **steering** — `inclusion` is valid; `fileMatch` has a non-empty pattern; `auto` has `name` + `description`. -- **spec (requirements)** — every criterion starts with an EARS keyword; no `should`; requirement headers unique. -- **spec (design)** — `## Architecture Pattern & Boundary Map` and `## File Structure Plan` present; every requirement ID appears in the `## Requirements Traceability` table; `design.md` ≤ 1000 lines. -- **spec (tasks)** — leaves have `_Requirements:_`; referenced IDs exist; every `(P)` has `_Boundary:_` matching a design component; `_Depends:_` IDs exist; no two `(P)` share a boundary. -- **skill** — `name` matches the dir; `description` non-empty; body ≤ 500 lines / ~5k tokens; required headings present; every `references/` file cited. -- **mcp** — well-formed JSON; exactly one transport per server; remote `type` is `sse`/`http`; no empty env keys. - -Run `npx @protonspy/csdd spec status ` between every step. - -## Anti-patterns — refuse to do these - -Skipping phase gates outside authorized fast-track work · hand-editing -`spec.json`/frontmatter/`.mcp.json` · writing tasks as file edits · omitting -`_Boundary:_` on `(P)` tasks · letting `design.md` exceed 1000 lines · treating -`research.md` findings as decoration · putting secrets in steering/specs · -skipping the "why" when adding a steering rule · granting broad tool scope to a -sub-agent · adding MCP servers with broad scope or `--auto-approve` · adding AI or -assistant attribution (`Co-authored-by:`, `Claude-Session`, "Generated with…") to -commits or PRs. - -Escalate to a human when a feature touches auth, payments, PII, or regulated -data; when a change needs `--force` past a gate; or before anything destructive. - -## Project memory (steering) - -Steering files are always-on project memory, loaded via the `@`-imports below. -`csdd` keeps this list in sync with `.claude/steering/` — do not hand-edit -between the markers. - - - - -## Layout - -- `.claude/steering/` — project memory imported above. -- `specs//` — per-feature contracts (`spec.json` + `requirements.md` + `design.md` + `tasks.md`). -- `.claude/rules/` — generation rules and review gates the agent runs mechanically. -- `.claude/templates/` — templates the agent uses when generating artifacts. -- `.claude/skills//` — executable workflow skills. -- `.claude/agents/.md` — custom sub-agents with least-privilege tool scope. -- `.claude/commands/.md` — slash commands (`/csdd-commit` generates the commit message from the diff + active spec). -- `.claude/hooks/` + `.claude/settings.json` — deterministic automation (format on edit, command safety, stop reminder). -- `.githooks/pre-push` — the test gate at push time. Enable once: `git config core.hooksPath .githooks`. -- `.github/pull_request_template.md` — evidence-bearing PR body. -- `.mcp.json` — Model Context Protocol servers. +# Project Entry Point + +This repository follows a Spec-Driven Development (SDD) + Test-Driven +Development (TDD) workflow, native to **Claude Code**. Steering, specs, skills, +and custom sub-agents are managed by the `csdd` CLI — or, preferably, its MCP +tools (see *Driving csdd* below). + +**This file is the contract *and* the operational reference.** Everything an +agent needs to drive `csdd` — the gates, command surface, and phrasing rules — +lives here. Read it before you create, generate, approve, or edit *any* steering +file, spec, skill, or agent. For the full CLI surface, run +`npx @protonspy/csdd --help`. + +## STOP — hard rules, enforced not advisory + +Breaking one of these corrupts the contract layer that every human review and +every gate depends on: + +1. **`csdd` is the only sanctioned author of managed files.** Create and change + steering, specs, skills, and agents **only** through the `csdd_*` MCP tools or + the `csdd` CLI — never by writing the files directly. +2. **Never hand-edit `spec.json`.** It records phase approvals and + `ready_for_implementation`. Writing to it directly — even to "unblock" + yourself — bypasses the human gate and is a process violation. Phases are + crossed *only* with `npx @protonspy/csdd spec approve --phase `, which a + human authorizes. +3. **Never hand-edit frontmatter, task annotations, or `.mcp.json`.** If a + generated file is wrong, regenerate it from the template and edit the *body*, + then re-validate — do not patch the machine-managed parts by hand. +4. **Never skip a phase gate.** `requirements → design → tasks → implementation` + is strictly ordered; you cannot generate a phase until the prior one is + human-approved. `--force` is allowed **only** when the user explicitly + authorizes a fast-track / Quick Plan. +5. **When a rule blocks you, stop and surface it** — report the blocked item to + the human. Do not route around the gate. + +## Driving csdd — prefer the MCP tools for the dev flow + +`npx @protonspy/csdd init` registers a **`csdd` MCP server** in `.mcp.json`. When it is +connected, drive the development flow with its **`csdd_*` tools** instead of +shelling out: + +| Resource | Tools | CLI fallback | +|---|---|---| +| steering | `csdd_steering_*` | `npx @protonspy/csdd steering …` | +| spec | `csdd_spec_*` | `npx @protonspy/csdd spec …` | +| skill | `csdd_skill_*` | `npx @protonspy/csdd skill …` | +| agent | `csdd_agent_*` | `npx @protonspy/csdd agent …` | + +Typed parameters (the `artifact`, `phase`, and `inclusion` enums, required +fields) stop you from passing invalid values, and the server builds the exact +argv. The **phase gates, validators, and exit codes are identical** either way. +Use the **CLI** when the MCP server isn't connected, and always for **setup and +management** — `init`, `copy`, `mcp`, and `export` are intentionally *not* tools. + +## Mental model — read this first + +Two distinctions matter more than anything else: + +- **Specification vs Design.** *Specification* is the contract: + `requirements.md`, the File Structure Plan inside `design.md`, and the + `_Boundary:_`/`_Depends:_` annotations on `tasks.md`. **Humans review and + approve this.** *Design* is the implementation space *inside* that contract — + components, internals, sequencing. **You are free here once the specification + is approved.** +- **Phase gates.** Every spec advances `requirements → design → tasks → + implementation`. You cannot generate a phase until the prior one is + human-approved (recorded in `spec.json`). The CLI enforces this mechanically; + `--force` is for explicitly authorized Quick Plans only. + +## Working discipline — how you write inside the gates + +The gates decide *what* to build and *when*; these decide *how* you write it. +They bias toward caution over speed — use judgment on genuinely trivial changes. + +- **Think before you code.** Don't assume, and don't hide confusion. State your + assumptions; when a requirement reads two ways, surface both instead of + silently choosing one; when a simpler path exists, say so. If something is + unclear, stop and name it — the STOP reflex above, applied to design and not + just to gates. +- **Simplicity first.** Write the least code that satisfies the task and makes + its tests pass — nothing speculative. No unrequested features, no abstraction + for single-use code, no configurability nobody asked for, no error handling + for impossible states. If 200 lines could be 50, rewrite it. This is the + *Design* freedom above, spent frugally. +- **Stay surgical.** Touch only what the task needs. Don't reformat, "improve" + adjacent code, or refactor what isn't broken; match the surrounding style even + when you'd write it differently. Remove only the imports and symbols your own + change orphaned — flag pre-existing dead code, don't delete it. Every changed + line should trace to a requirement the task carries. +- **Drive to a verifiable goal.** Turn the task into a checkable outcome before + writing code — "add validation" becomes "a failing test for invalid input, + then make it pass". That is the red→green loop the cycle already mandates: + sharp success criteria let you loop to green on your own; vague ones ("make it + work") force round-trips. + +## Command cheat sheet + +Names are **kebab-case** (`api-conventions`, `photo-albums`, `code-reviewer`); +the CLI rejects anything else. Global flags on every command: `--root PATH` +(override workspace), `--force` (override safety checks), `-h`/`--help`, +`-v`/`--version`. Exit codes: `0` OK, `1` user error, `2` validation failure. + +```bash +# bootstrap (one time per repo) +npx @protonspy/csdd init [--with-baseline] [--exclude agents,skills,…] [--no-mcp] + +# copy one shipped artifact into the workspace (pairs with init --exclude) +npx @protonspy/csdd copy skills/dev-architecture # or agents/…, rules/…, commands/…, hooks/…, templates/…, steering/… + +# steering (project memory) +npx @protonspy/csdd steering init +npx @protonspy/csdd steering create NAME --inclusion {always|fileMatch|manual|auto} \ + [--pattern GLOB]... [--description TEXT] +npx @protonspy/csdd steering {list,show,delete,validate} [NAME] + +# specs — generate → validate → human approves, one phase at a time +npx @protonspy/csdd spec init FEATURE +npx @protonspy/csdd spec generate FEATURE --artifact {requirements|design|tasks|research|bugfix} [--force] +npx @protonspy/csdd spec approve FEATURE --phase {requirements|design|tasks} [--force] +npx @protonspy/csdd spec status FEATURE # show + validate in one shot +npx @protonspy/csdd spec {list,show,validate,delete} FEATURE + +# skills — executable workflow bundles +npx @protonspy/csdd skill create NAME --description TEXT +npx @protonspy/csdd skill {add-reference,add-script,add-asset} SKILL FILE +npx @protonspy/csdd skill {list,show,validate,delete} NAME + +# custom sub-agents — least privilege +npx @protonspy/csdd agent create NAME --description TEXT [--tools TOOL]... [--model M] +npx @protonspy/csdd agent {list,show,delete} NAME + +# mcp servers (.mcp.json) +npx @protonspy/csdd mcp add NAME --command CMD [--arg A]... [--env K=V]... # stdio +npx @protonspy/csdd mcp add NAME --url URL [--type sse|http] [--env K=V]... # remote +npx @protonspy/csdd mcp {list,show,enable,disable,remove,validate} [NAME] +``` + +## The development cycle (follow it in order) + +``` +requirements ──approve──▶ design ──approve──▶ tasks ──approve──▶ implementation + (human) (human) (human) ready_for_implementation: true +``` + +1. **Start each feature on a clean context:** `/clear`, then `npx @protonspy/csdd spec init `. +2. **Generate → review → approve, one phase at a time:** `spec generate --artifact requirements` → human reviews → `spec approve --phase requirements`; then `design`, then `tasks`. The gate refuses the next `generate` until the current phase is approved. +3. `ready_for_implementation` flips to `true` only after all three phases are approved — and only csdd flips it. Do not set it yourself. +4. **Branch before the first task:** `git switch -c /` in kebab-case (`feat/`, `fix/`, `chore/`, `refactor/`, `docs/`, `test/`, `perf/`). The PreToolUse hook blocks commits on the default branch. +5. Implementation runs one task per iteration with fresh-context sub-agents (`test-designer` enumerates the cases → `implementer` builds them → `code-reviewer`, plus `security-reviewer` when auth/secrets/input are touched), TDD red→green per task. Stay inside the approved design boundary. Independent `(P)` tasks in *different* `_Boundary:_` groups may run as parallel `implementer` sub-agents in isolated git worktrees; same-boundary tasks stay ordered and every `_Depends:_` is honored first. +6. Commit a slice only after review is clean, then push — the `pre-push` hook runs the test gate and blocks a red push. +7. **Close each feature with a `/clear`** once its PR ships, so context does not accumulate across features. +8. Steering is updated only when a new pattern emerges that the agent could not derive from the code. + +### Variants + +- **Bugfix** — replace `requirements.md` with `bugfix.md` (`spec generate --artifact bugfix`); the phase gate is skipped. The validator requires `Current Behavior:`, `Expected Behavior:`, `Unchanged Behavior:`, and `Root Cause`. Document the root cause **before** writing any code. +- **Research** — for brownfield or novel domains add `spec generate --artifact research` before design. Every finding in `research.md` must connect to a concrete commitment in `design.md`. +- **No spec needed** — small reversible changes (typos, one-line fixes) skip the spec layer. SDD is a contract layer, not a tax. + +## EARS — the only acceptable requirement phrasing + +Every criterion in `requirements.md` **must** use EARS. Trigger keywords +(`WHEN`/`WHILE`/`IF`/`WHERE`/`SHALL`/`THE SYSTEM`) are **fixed English** — localize +only the variable parts. Use **`SHALL`**, never `should`. One behavior per +criterion. Quantify ("fast"/"robust" are not testable). + +| Pattern | Template | +|---|---| +| Event-Driven | `WHEN THEN the system SHALL ` | +| State-Driven | `WHILE THE SYSTEM SHALL ` | +| Unwanted | `IF THEN the system SHALL ` | +| Optional | `WHERE THE SYSTEM SHALL ` | +| Ubiquitous | `THE SYSTEM SHALL ` | + +IDs are numeric and unique: each `### Requirement N:` header numbers its criteria +`1.`, `2.`, … which become IDs `N.1`, `N.2`, …. + +## Tasks — annotations, boundaries, parallel safety + +`tasks.md` is not a free-form todo list. Tasks describe **capabilities and +outcomes**, never file edits. Every task carries machine-readable annotations: + +| Annotation | When required | Format | +|---|---|---| +| `_Requirements:_` | On **every leaf task** | `_Requirements: 1.1, 1.2_` — numeric IDs that exist in `requirements.md`. | +| `_Boundary:_` | On every `(P)` task; optional on others | `_Boundary: AlbumService_` — must match a `### AlbumService` component in `design.md`. | +| `_Depends:_` | When a task needs another **across boundaries** | `_Depends: 1.1, 3.2_` — IDs that exist in `tasks.md`. Same-boundary order is implicit; don't list it. | +| `(P)` | Task can run parallel to `(P)` tasks in **other** boundaries | inline marker. Two `(P)` tasks **must not** share a boundary. | + +Sizing: major tasks 3–10 sub-tasks; leaves 1–3 hours. Phase order: +`## Phase 1: Foundation → Phase 2: Core → Phase 3: Integration → Phase 4: Validation`. + +## Steering, skills, agents, MCP — the essentials + +- **Steering** (`.claude/steering/*.md`) is always-on project memory, imported into the managed block below. `inclusion:` (`always|fileMatch|manual|auto`) documents intended scope. Put organizational patterns and the *why* here; **never** secrets, file dumps, or details derivable from code. Golden rule: *if new code follows existing patterns, steering should not need updating.* +- **Skills** (`.claude/skills//`) are executable capability bundles: `SKILL.md` (≤ 500 lines / ~5k tokens) + `references/` + `assets/` + `scripts/`. Required sections: `## Goal`, `## Execution Workflow`, `## Gotchas`, `## Verification Before Reporting`, `## Completion Criteria`. Every `references/` file must be cited by name with a load trigger. Build skills from real expertise, never generic LLM output. +- **Sub-agents** (`.claude/agents/.md`) default to `Read, Grep` (least privilege). The more powerful the agent, the smaller its scope and the larger the human review must be. +- **MCP servers** (`.mcp.json`) are managed only with `npx @protonspy/csdd mcp`. Each server sets exactly one transport — `command` (stdio) **or** `url` (remote), never both. Scope narrowly, avoid `--auto-approve`, pass secrets via `--env`, never inline them. + +## Validation — what the gates catch + +`csdd * validate` runs **mechanical** checks (exit `2` on issues; fix before +continuing): + +- **steering** — `inclusion` is valid; `fileMatch` has a non-empty pattern; `auto` has `name` + `description`. +- **spec (requirements)** — every criterion starts with an EARS keyword; no `should`; requirement headers unique. +- **spec (design)** — `## Architecture Pattern & Boundary Map` and `## File Structure Plan` present; every requirement ID appears in the `## Requirements Traceability` table; `design.md` ≤ 1000 lines. +- **spec (tasks)** — leaves have `_Requirements:_`; referenced IDs exist; every `(P)` has `_Boundary:_` matching a design component; `_Depends:_` IDs exist; no two `(P)` share a boundary. +- **skill** — `name` matches the dir; `description` non-empty; body ≤ 500 lines / ~5k tokens; required headings present; every `references/` file cited. +- **mcp** — well-formed JSON; exactly one transport per server; remote `type` is `sse`/`http`; no empty env keys. + +Run `npx @protonspy/csdd spec status ` between every step. + +## Anti-patterns — refuse to do these + +Skipping phase gates outside authorized fast-track work · hand-editing +`spec.json`/frontmatter/`.mcp.json` · writing tasks as file edits · omitting +`_Boundary:_` on `(P)` tasks · letting `design.md` exceed 1000 lines · treating +`research.md` findings as decoration · putting secrets in steering/specs · +skipping the "why" when adding a steering rule · granting broad tool scope to a +sub-agent · adding MCP servers with broad scope or `--auto-approve` · adding AI or +assistant attribution (`Co-authored-by:`, `Claude-Session`, "Generated with…") to +commits or PRs. + +Escalate to a human when a feature touches auth, payments, PII, or regulated +data; when a change needs `--force` past a gate; or before anything destructive. + +## Project memory (steering) + +Steering files are always-on project memory, loaded via the `@`-imports below. +`csdd` keeps this list in sync with `.claude/steering/` — do not hand-edit +between the markers. + + + + +## Layout + +- `.claude/steering/` — project memory imported above. +- `specs//` — per-feature contracts (`spec.json` + `requirements.md` + `design.md` + `tasks.md`). +- `.claude/rules/` — generation rules and review gates the agent runs mechanically. +- `.claude/templates/` — templates the agent uses when generating artifacts. +- `.claude/skills//` — executable workflow skills. +- `.claude/agents/.md` — custom sub-agents with least-privilege tool scope. +- `.claude/commands/.md` — slash commands (`/csdd-commit` generates the commit message from the diff + active spec). +- `.claude/hooks/` + `.claude/settings.json` — deterministic automation (format on edit, command safety, stop reminder). +- `.githooks/pre-push` — the test gate at push time. Enable once: `git config core.hooksPath .githooks`. +- `.github/pull_request_template.md` — evidence-bearing PR body. +- `.mcp.json` — Model Context Protocol servers. diff --git a/internal/templater/templates/root/mcp.json.tmpl b/internal/templater/templates/root/mcp.json.tmpl index 45cba2f..da39e4f 100644 --- a/internal/templater/templates/root/mcp.json.tmpl +++ b/internal/templater/templates/root/mcp.json.tmpl @@ -1,3 +1,3 @@ -{ - "mcpServers": {} -} +{ + "mcpServers": {} +} diff --git a/internal/templater/templates/root/settings.json.tmpl b/internal/templater/templates/root/settings.json.tmpl index b48f2a5..d8d1b3b 100644 --- a/internal/templater/templates/root/settings.json.tmpl +++ b/internal/templater/templates/root/settings.json.tmpl @@ -10,7 +10,7 @@ ], "PostToolUse": [ { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write|NotebookEdit", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-after-edit.sh" } ] diff --git a/internal/templater/templates/rules/design-discovery-full.md.tmpl b/internal/templater/templates/rules/design-discovery-full.md.tmpl index 23520af..7976d20 100644 --- a/internal/templater/templates/rules/design-discovery-full.md.tmpl +++ b/internal/templater/templates/rules/design-discovery-full.md.tmpl @@ -1,20 +1,20 @@ -# Design Discovery — Full - -Use for greenfield work or substantial changes when the design space is open. - -## Inputs -- requirements.md (approved) -- product.md, tech.md, structure.md -- Any reference architectures or constraints from leadership - -## Activities -1. Enumerate candidate architectures with trade-offs. -2. Identify boundaries that minimize cross-component coupling. -3. Sketch the File Structure Plan against the chosen boundaries. -4. Walk the golden flow end-to-end; identify failure points. -5. Walk the top 3 error flows; identify recovery paths. -6. Decide on observability and rollout strategy. - -## Outputs -- design.md with Boundary Map, File Structure Plan, Components, Flows, Traceability. -- research.md when investigation precedes commitment. +# Design Discovery — Full + +Use for greenfield work or substantial changes when the design space is open. + +## Inputs +- requirements.md (approved) +- product.md, tech.md, structure.md +- Any reference architectures or constraints from leadership + +## Activities +1. Enumerate candidate architectures with trade-offs. +2. Identify boundaries that minimize cross-component coupling. +3. Sketch the File Structure Plan against the chosen boundaries. +4. Walk the golden flow end-to-end; identify failure points. +5. Walk the top 3 error flows; identify recovery paths. +6. Decide on observability and rollout strategy. + +## Outputs +- design.md with Boundary Map, File Structure Plan, Components, Flows, Traceability. +- research.md when investigation precedes commitment. diff --git a/internal/templater/templates/rules/design-discovery-light.md.tmpl b/internal/templater/templates/rules/design-discovery-light.md.tmpl index 96b7f84..3c49962 100644 --- a/internal/templater/templates/rules/design-discovery-light.md.tmpl +++ b/internal/templater/templates/rules/design-discovery-light.md.tmpl @@ -1,17 +1,17 @@ -# Design Discovery — Light - -Use for additive changes inside well-understood subsystems. - -## Inputs -- requirements.md (approved) -- existing design.md or component documentation - -## Activities -1. Identify the existing boundary(s) the work lives in. -2. Confirm contracts of touched components. -3. Plan the minimal set of files to change. -4. Verify observability still covers the changed paths. - -## Outputs -- design.md sections: Existing Architecture Analysis, File Structure Plan, - Components touched, Traceability, Rollout/Rollback if relevant. +# Design Discovery — Light + +Use for additive changes inside well-understood subsystems. + +## Inputs +- requirements.md (approved) +- existing design.md or component documentation + +## Activities +1. Identify the existing boundary(s) the work lives in. +2. Confirm contracts of touched components. +3. Plan the minimal set of files to change. +4. Verify observability still covers the changed paths. + +## Outputs +- design.md sections: Existing Architecture Analysis, File Structure Plan, + Components touched, Traceability, Rollout/Rollback if relevant. diff --git a/internal/templater/templates/rules/design-principles.md.tmpl b/internal/templater/templates/rules/design-principles.md.tmpl index 3c7fa2b..3818959 100644 --- a/internal/templater/templates/rules/design-principles.md.tmpl +++ b/internal/templater/templates/rules/design-principles.md.tmpl @@ -1,23 +1,23 @@ -# Design Principles - -## Component Design -- Single responsibility per component. -- Explicit dependencies in/out — no global state for cross-component data. -- Contracts (interfaces, message shapes) declared before bodies. - -## Dependency Direction -- Outer layers depend on inner; never the reverse. -- API → Service → Repository → Storage. Storage MUST NOT call Service. - -## Type Safety -- Avoid `any` / untyped escapes; if used, document why and scope tightly. -- Validate untrusted data at the boundary, not deep in the call stack. - -## Error Handling -- Errors carry codes, messages, and correlation context; no leaking internals. -- Distinguish recoverable (retry) from unrecoverable (fail-fast) errors. -- Idempotency keys on every externally-triggered mutation. - -## Observability -- Every component declares what it logs, what it measures, and what it traces. -- No metric labels carry high-cardinality values. +# Design Principles + +## Component Design +- Single responsibility per component. +- Explicit dependencies in/out — no global state for cross-component data. +- Contracts (interfaces, message shapes) declared before bodies. + +## Dependency Direction +- Outer layers depend on inner; never the reverse. +- API → Service → Repository → Storage. Storage MUST NOT call Service. + +## Type Safety +- Avoid `any` / untyped escapes; if used, document why and scope tightly. +- Validate untrusted data at the boundary, not deep in the call stack. + +## Error Handling +- Errors carry codes, messages, and correlation context; no leaking internals. +- Distinguish recoverable (retry) from unrecoverable (fail-fast) errors. +- Idempotency keys on every externally-triggered mutation. + +## Observability +- Every component declares what it logs, what it measures, and what it traces. +- No metric labels carry high-cardinality values. diff --git a/internal/templater/templates/rules/design-review-gate.md.tmpl b/internal/templater/templates/rules/design-review-gate.md.tmpl index f7277f3..79f753a 100644 --- a/internal/templater/templates/rules/design-review-gate.md.tmpl +++ b/internal/templater/templates/rules/design-review-gate.md.tmpl @@ -1,19 +1,19 @@ -# Design Review Gate - -## Scope and Coverage -- [ ] Boundary Map present (Mermaid). -- [ ] File Structure Plan fully populated and consistent with Boundary Map. -- [ ] Every component has Intent, Requirements, Responsibilities, Dependencies, Contract. -- [ ] System Flows cover golden path AND top error cases. -- [ ] Errors, retry, timeout, idempotency addressed. -- [ ] Observability included. -- [ ] Rollout/rollback plan when needed. - -## Quality Criteria -- [ ] Architecture consistent with tech.md and structure.md. -- [ ] Trade-offs and rejected alternatives documented. - -## Mechanical Checks -- [ ] Every requirement ID in requirements.md appears in the Requirements Traceability table. -- [ ] Every component referenced in Boundary Map has a Components and Interfaces entry. -- [ ] design.md is under 1,000 lines. +# Design Review Gate + +## Scope and Coverage +- [ ] Boundary Map present (Mermaid). +- [ ] File Structure Plan fully populated and consistent with Boundary Map. +- [ ] Every component has Intent, Requirements, Responsibilities, Dependencies, Contract. +- [ ] System Flows cover golden path AND top error cases. +- [ ] Errors, retry, timeout, idempotency addressed. +- [ ] Observability included. +- [ ] Rollout/rollback plan when needed. + +## Quality Criteria +- [ ] Architecture consistent with tech.md and structure.md. +- [ ] Trade-offs and rejected alternatives documented. + +## Mechanical Checks +- [ ] Every requirement ID in requirements.md appears in the Requirements Traceability table. +- [ ] Every component referenced in Boundary Map has a Components and Interfaces entry. +- [ ] design.md is under 1,000 lines. diff --git a/internal/templater/templates/rules/design-synthesis.md.tmpl b/internal/templater/templates/rules/design-synthesis.md.tmpl index 542343f..bbb0fd6 100644 --- a/internal/templater/templates/rules/design-synthesis.md.tmpl +++ b/internal/templater/templates/rules/design-synthesis.md.tmpl @@ -1,14 +1,14 @@ -# Design Synthesis - -Synthesize discovery findings into the canonical design.md sections. - -## Rules -- The Boundary Map and the File Structure Plan SHALL be consistent. -- Every requirement appears in the Requirements Traceability table. -- Every component has Intent, Requirements, Responsibilities, Dependencies, Contract. -- Trade-offs and rejected alternatives are recorded with reasons. - -## Anti-patterns -- design.md over 1,000 lines — split the spec. -- Components without contracts. -- Flows that skip error paths. +# Design Synthesis + +Synthesize discovery findings into the canonical design.md sections. + +## Rules +- The Boundary Map and the File Structure Plan SHALL be consistent. +- Every requirement appears in the Requirements Traceability table. +- Every component has Intent, Requirements, Responsibilities, Dependencies, Contract. +- Trade-offs and rejected alternatives are recorded with reasons. + +## Anti-patterns +- design.md over 1,000 lines — split the spec. +- Components without contracts. +- Flows that skip error paths. diff --git a/internal/templater/templates/rules/ears-format.md.tmpl b/internal/templater/templates/rules/ears-format.md.tmpl index e743923..5c77ac9 100644 --- a/internal/templater/templates/rules/ears-format.md.tmpl +++ b/internal/templater/templates/rules/ears-format.md.tmpl @@ -1,29 +1,29 @@ -# EARS Format - -EARS (Easy Approach to Requirements Syntax) is the requirement language for -this project. Trigger keywords are fixed English and SHALL NOT be localized. - -## Templates - -| Pattern | Template | -|---|---| -| Event-Driven | `WHEN [event] THE SYSTEM SHALL [response]` | -| State-Driven | `WHILE [precondition] THE SYSTEM SHALL [behavior]` | -| Unwanted Behavior | `IF [trigger] THEN the system SHALL [response]` | -| Optional Feature | `WHERE [feature/flag] THE SYSTEM SHALL [behavior]` | -| Ubiquitous | `THE SYSTEM SHALL [behavior]` | - -## Rules - -- Use `shall`, not `should`. -- Localize only the variable parts; keep trigger keywords in English. -- One behavior per criterion; split compound statements. -- Phrase objectively. Avoid "fast", "robust", "user-friendly" — quantify. -- Every criterion SHALL be testable. - -## Mechanical checks (run before requesting approval) - -- [ ] Every numbered criterion starts with `WHEN`, `WHILE`, `IF`, `WHERE`, or `THE SYSTEM`. -- [ ] No criterion uses `should`. -- [ ] No implementation language ("how") — only behavior ("what"). -- [ ] Numeric IDs are unique inside the file. +# EARS Format + +EARS (Easy Approach to Requirements Syntax) is the requirement language for +this project. Trigger keywords are fixed English and SHALL NOT be localized. + +## Templates + +| Pattern | Template | +|---|---| +| Event-Driven | `WHEN [event] THE SYSTEM SHALL [response]` | +| State-Driven | `WHILE [precondition] THE SYSTEM SHALL [behavior]` | +| Unwanted Behavior | `IF [trigger] THEN the system SHALL [response]` | +| Optional Feature | `WHERE [feature/flag] THE SYSTEM SHALL [behavior]` | +| Ubiquitous | `THE SYSTEM SHALL [behavior]` | + +## Rules + +- Use `shall`, not `should`. +- Localize only the variable parts; keep trigger keywords in English. +- One behavior per criterion; split compound statements. +- Phrase objectively. Avoid "fast", "robust", "user-friendly" — quantify. +- Every criterion SHALL be testable. + +## Mechanical checks (run before requesting approval) + +- [ ] Every numbered criterion starts with `WHEN`, `WHILE`, `IF`, `WHERE`, or `THE SYSTEM`. +- [ ] No criterion uses `should`. +- [ ] No implementation language ("how") — only behavior ("what"). +- [ ] Numeric IDs are unique inside the file. diff --git a/internal/templater/templates/rules/gap-analysis.md.tmpl b/internal/templater/templates/rules/gap-analysis.md.tmpl index dbd984c..dbe2408 100644 --- a/internal/templater/templates/rules/gap-analysis.md.tmpl +++ b/internal/templater/templates/rules/gap-analysis.md.tmpl @@ -1,17 +1,17 @@ -# Gap Analysis (Brownfield) - -Run after requirements approval, before design, for changes that touch -existing subsystems. - -## Activities -- Map each requirement to the existing module that owns the behavior. -- Identify behaviors that contradict existing invariants. -- Identify implicit contracts that must be preserved (Unchanged Behavior). -- Catalog tests that already cover affected paths and the ones that do not. - -## Outputs -- A gap report appended to research.md, listing: - - Mapped behaviors (req → module) - - Conflicts (req vs current behavior) - - Implicit contracts to preserve - - Test coverage gaps +# Gap Analysis (Brownfield) + +Run after requirements approval, before design, for changes that touch +existing subsystems. + +## Activities +- Map each requirement to the existing module that owns the behavior. +- Identify behaviors that contradict existing invariants. +- Identify implicit contracts that must be preserved (Unchanged Behavior). +- Catalog tests that already cover affected paths and the ones that do not. + +## Outputs +- A gap report appended to research.md, listing: + - Mapped behaviors (req → module) + - Conflicts (req vs current behavior) + - Implicit contracts to preserve + - Test coverage gaps diff --git a/internal/templater/templates/rules/requirements-review-gate.md.tmpl b/internal/templater/templates/rules/requirements-review-gate.md.tmpl index 94de904..e46e88c 100644 --- a/internal/templater/templates/rules/requirements-review-gate.md.tmpl +++ b/internal/templater/templates/rules/requirements-review-gate.md.tmpl @@ -1,22 +1,22 @@ -# Requirements Review Gate - -The agent runs this checklist mechanically and reports results before the -human reviews. - -## Scope and Coverage -- [ ] User stories present with persona, goal, value. -- [ ] Acceptance criteria cover golden path AND error/edge cases. -- [ ] Permissions and authentication considered. -- [ ] Non-functional requirements declared (latency, resilience, observability, accessibility). -- [ ] Integration constraints declared. - -## Quality Criteria -- [ ] EARS phrasing on every criterion. -- [ ] Each criterion is testable and single-behavior. -- [ ] No implementation language ("how"). -- [ ] Ambiguities and open questions enumerated, not hidden. - -## Mechanical Checks -- [ ] Numeric IDs are present and unique. -- [ ] No criterion uses `should` instead of `SHALL`. -- [ ] Trigger keywords (`WHEN`/`WHILE`/`IF`/`WHERE`/`SHALL`) are in English. +# Requirements Review Gate + +The agent runs this checklist mechanically and reports results before the +human reviews. + +## Scope and Coverage +- [ ] User stories present with persona, goal, value. +- [ ] Acceptance criteria cover golden path AND error/edge cases. +- [ ] Permissions and authentication considered. +- [ ] Non-functional requirements declared (latency, resilience, observability, accessibility). +- [ ] Integration constraints declared. + +## Quality Criteria +- [ ] EARS phrasing on every criterion. +- [ ] Each criterion is testable and single-behavior. +- [ ] No implementation language ("how"). +- [ ] Ambiguities and open questions enumerated, not hidden. + +## Mechanical Checks +- [ ] Numeric IDs are present and unique. +- [ ] No criterion uses `should` instead of `SHALL`. +- [ ] Trigger keywords (`WHEN`/`WHILE`/`IF`/`WHERE`/`SHALL`) are in English. diff --git a/internal/templater/templates/rules/steering-principles.md.tmpl b/internal/templater/templates/rules/steering-principles.md.tmpl index 9acb8d9..b9474f3 100644 --- a/internal/templater/templates/rules/steering-principles.md.tmpl +++ b/internal/templater/templates/rules/steering-principles.md.tmpl @@ -1,23 +1,23 @@ -# Steering Principles - -## The Golden Rule -> If new code follows existing patterns, steering should not need updating. - -## What belongs in steering -- Organizational patterns, naming conventions, import strategies. -- Architectural decisions and their rationale. -- Technology standards and version policy. -- Security, testing, observability, API conventions. - -## What does NOT belong -- File listings or directory dumps. -- Implementation details derivable from the code. -- Secrets, tokens, credentials, customer data. -- Agent-specific tooling internals. - -## Per-file checklist -- [ ] One domain per file. -- [ ] Descriptive name. -- [ ] Includes the "why", not just the rule. -- [ ] Includes before/after examples where they clarify. -- [ ] Reviewed during architecture changes. +# Steering Principles + +## The Golden Rule +> If new code follows existing patterns, steering should not need updating. + +## What belongs in steering +- Organizational patterns, naming conventions, import strategies. +- Architectural decisions and their rationale. +- Technology standards and version policy. +- Security, testing, observability, API conventions. + +## What does NOT belong +- File listings or directory dumps. +- Implementation details derivable from the code. +- Secrets, tokens, credentials, customer data. +- Agent-specific tooling internals. + +## Per-file checklist +- [ ] One domain per file. +- [ ] Descriptive name. +- [ ] Includes the "why", not just the rule. +- [ ] Includes before/after examples where they clarify. +- [ ] Reviewed during architecture changes. diff --git a/internal/templater/templates/rules/tasks-generation.md.tmpl b/internal/templater/templates/rules/tasks-generation.md.tmpl index 8e1fdb5..4f3fd06 100644 --- a/internal/templater/templates/rules/tasks-generation.md.tmpl +++ b/internal/templater/templates/rules/tasks-generation.md.tmpl @@ -1,60 +1,60 @@ -# Tasks Generation - -## Sizing -- Major tasks: 3-10 sub-tasks. -- Sub-tasks: 1-3 hours of focused work. -- Phases: Foundation → Core → Integration → Validation. - -## Annotations -- `_Requirements: 1.1, 1.2_` — numeric IDs only, no descriptions. -- `_Boundary: ComponentName_` — required on `(P)` tasks; matches component names from design.md. -- `_Depends: 3.1, 4.2_` — cross-boundary dependencies only; sequential order is implicit. -- `(P)` — parallel-capable, runs alongside other (P) tasks in different boundaries. - -## Development flow (read `development_flow` from spec.json first) - -The spec's `development_flow` decides how behavior tasks are shaped. Resolve it -before generating tasks (absent ⇒ `tdd`): - -- **`tdd`** (default) and **`tdd-e2e`** — every behavior is delivered as a **RED test - sub-task immediately followed by a GREEN implementation sub-task**. The test is written - first and must fail for the expected reason before the implementation exists. Name the - pair explicitly: `2.1 RED — write the failing test for X`, `2.2 GREEN — minimal - implementation to pass 2.1`. The GREEN sub-task depends on its RED sub-task; refactor - only under green. -- **`unit`** — write the implementation, then cover it with unit tests in the **same** - task. No preceding RED sub-task is required, but **every behavior task still carries a - test sub-task** covering that behavior — `unit` skips the RED-first ritual, not the test. -- **`tdd-e2e`** — in addition to the per-behavior RED/GREEN pairs, **Phase 4 (Validation) - includes an end-to-end task** covering the golden flow and the top error flows. - -Common to every flow: -- One test per **observable behavior**, not one per code branch — keep the test count - proportional to behavior, not to internal control flow. -- Pure scaffolding (migrations, type skeletons, config) needs no test sub-task. -- Phase 4 (Validation) holds end-to-end tests, not the unit tests — those live with their - behavior in Phases 2–3. -- Run unit tests through the csdd CLI so the spec keeps traceable evidence (**all flows**): - `npx @protonspy/csdd spec test-report --run --lang ` writes `specs//test-report.json`. - Phase 4 SHOULD include a task that records this evidence (tests green, coverage captured). - -## Avoid -- Generic tasks like "implement the backend". -- File-level instructions like "edit albums.ts". -- A behavior with no test sub-task at all (under `tdd`/`tdd-e2e`, no *preceding* test). -- A test per branch instead of per observable behavior (test bloat). - -## Prefer -- Capabilities and outcomes: - - "Create POST /sessions endpoint with payload validation, 201 response, and 400 for invalid data." - - "Add a unit test for login lockout after 5 failed attempts." - - "Add metric auth.login.failed with non-sensitive labels." - -## Mechanical Checks -- [ ] Every leaf task carries `_Requirements:_` with numeric IDs only. -- [ ] Every `(P)` task carries `_Boundary:_`. -- [ ] Every `_Depends:_` references a task ID that exists in this file. -- [ ] No two `(P)` tasks share the same boundary. -- [ ] Under `tdd`/`tdd-e2e`: every behavior implementation (GREEN) sub-task is preceded by a test (RED) sub-task for the same behavior. -- [ ] Under `unit`: every behavior task carries a test sub-task (no preceding RED required). -- [ ] Under `tdd-e2e`: Phase 4 includes an end-to-end task for the golden + top error flows. +# Tasks Generation + +## Sizing +- Major tasks: 3-10 sub-tasks. +- Sub-tasks: 1-3 hours of focused work. +- Phases: Foundation → Core → Integration → Validation. + +## Annotations +- `_Requirements: 1.1, 1.2_` — numeric IDs only, no descriptions. +- `_Boundary: ComponentName_` — required on `(P)` tasks; matches component names from design.md. +- `_Depends: 3.1, 4.2_` — cross-boundary dependencies only; sequential order is implicit. +- `(P)` — parallel-capable, runs alongside other (P) tasks in different boundaries. + +## Development flow (read `development_flow` from spec.json first) + +The spec's `development_flow` decides how behavior tasks are shaped. Resolve it +before generating tasks (absent ⇒ `tdd`): + +- **`tdd`** (default) and **`tdd-e2e`** — every behavior is delivered as a **RED test + sub-task immediately followed by a GREEN implementation sub-task**. The test is written + first and must fail for the expected reason before the implementation exists. Name the + pair explicitly: `2.1 RED — write the failing test for X`, `2.2 GREEN — minimal + implementation to pass 2.1`. The GREEN sub-task depends on its RED sub-task; refactor + only under green. +- **`unit`** — write the implementation, then cover it with unit tests in the **same** + task. No preceding RED sub-task is required, but **every behavior task still carries a + test sub-task** covering that behavior — `unit` skips the RED-first ritual, not the test. +- **`tdd-e2e`** — in addition to the per-behavior RED/GREEN pairs, **Phase 4 (Validation) + includes an end-to-end task** covering the golden flow and the top error flows. + +Common to every flow: +- One test per **observable behavior**, not one per code branch — keep the test count + proportional to behavior, not to internal control flow. +- Pure scaffolding (migrations, type skeletons, config) needs no test sub-task. +- Phase 4 (Validation) holds end-to-end tests, not the unit tests — those live with their + behavior in Phases 2–3. +- Run unit tests through the csdd CLI so the spec keeps traceable evidence (**all flows**): + `npx @protonspy/csdd spec test-report --run --lang ` writes `specs//test-report.json`. + Phase 4 SHOULD include a task that records this evidence (tests green, coverage captured). + +## Avoid +- Generic tasks like "implement the backend". +- File-level instructions like "edit albums.ts". +- A behavior with no test sub-task at all (under `tdd`/`tdd-e2e`, no *preceding* test). +- A test per branch instead of per observable behavior (test bloat). + +## Prefer +- Capabilities and outcomes: + - "Create POST /sessions endpoint with payload validation, 201 response, and 400 for invalid data." + - "Add a unit test for login lockout after 5 failed attempts." + - "Add metric auth.login.failed with non-sensitive labels." + +## Mechanical Checks +- [ ] Every leaf task carries `_Requirements:_` with numeric IDs only. +- [ ] Every `(P)` task carries `_Boundary:_`. +- [ ] Every `_Depends:_` references a task ID that exists in this file. +- [ ] No two `(P)` tasks share the same boundary. +- [ ] Under `tdd`/`tdd-e2e`: every behavior implementation (GREEN) sub-task is preceded by a test (RED) sub-task for the same behavior. +- [ ] Under `unit`: every behavior task carries a test sub-task (no preceding RED required). +- [ ] Under `tdd-e2e`: Phase 4 includes an end-to-end task for the golden + top error flows. diff --git a/internal/templater/templates/rules/tasks-parallel-analysis.md.tmpl b/internal/templater/templates/rules/tasks-parallel-analysis.md.tmpl index d094fb2..7ca8c80 100644 --- a/internal/templater/templates/rules/tasks-parallel-analysis.md.tmpl +++ b/internal/templater/templates/rules/tasks-parallel-analysis.md.tmpl @@ -1,19 +1,19 @@ -# Tasks Parallel Analysis - -Parallel-safety rules for `(P)` tasks. Run before claiming tasks.md is ready. - -## Safety Rules -- Two `(P)` tasks MUST NOT share a boundary. -- A `(P)` task MUST NOT modify files that another concurrent task reads/writes. -- A `(P)` task MAY depend on completed non-parallel work via `_Depends:_`. - -## Detection Routine -1. Collect every `(P)` task with its `_Boundary:_` value. -2. Group by boundary; any group with > 1 task is an immediate failure. -3. Build the file set each task touches (from design.md File Structure Plan). -4. Reject any pair of `(P)` tasks with overlapping file sets. -5. Walk `_Depends:_` edges; reject cycles. - -## Output -A parallel-safety report appended to tasks.md as a comment block when issues -are found. The report SHALL list each violation with the offending task IDs. +# Tasks Parallel Analysis + +Parallel-safety rules for `(P)` tasks. Run before claiming tasks.md is ready. + +## Safety Rules +- Two `(P)` tasks MUST NOT share a boundary. +- A `(P)` task MUST NOT modify files that another concurrent task reads/writes. +- A `(P)` task MAY depend on completed non-parallel work via `_Depends:_`. + +## Detection Routine +1. Collect every `(P)` task with its `_Boundary:_` value. +2. Group by boundary; any group with > 1 task is an immediate failure. +3. Build the file set each task touches (from design.md File Structure Plan). +4. Reject any pair of `(P)` tasks with overlapping file sets. +5. Walk `_Depends:_` edges; reject cycles. + +## Output +A parallel-safety report appended to tasks.md as a comment block when issues +are found. The report SHALL list each violation with the offending task IDs. diff --git a/internal/templater/templates/skill/SKILL.md.tmpl b/internal/templater/templates/skill/SKILL.md.tmpl index 3f3a4f7..34c5129 100644 --- a/internal/templater/templates/skill/SKILL.md.tmpl +++ b/internal/templater/templates/skill/SKILL.md.tmpl @@ -1,47 +1,47 @@ ---- -name: {{.Name}} -description: {{.Description}} -{{- if .Model}} -model: {{.Model}} -{{- end}} -{{- if .Effort}} -effort: {{.Effort}} -{{- end}} ---- - -# {{.Title}} - -## Goal -[What problem this skill solves, in one paragraph.] - -## Default Operation Mode -[plan-first vs direct-implementation; what the user expects as the first deliverable.] - -## Collect Inputs First -- [Required context the agent must collect before editing files.] -- [Each input is a question that has to be answered.] - -## Execution Workflow - -### 1) [Phase 1 name] -[What to do, what to read, what to produce.] - -### 2) [Phase 2 name] -[...] - -## Gotchas -- [Non-obvious facts that defy reasonable assumptions.] -- [Each gotcha is a correction to a mistake the agent will otherwise make.] - -## Verification Before Reporting -- Run: `` -- Check: `` -- If blocked: report the blocked step explicitly, do not silently skip. - -## Completion Criteria -- [ ] [Concrete artifact 1 exists at ] -- [ ] [Validation 1 passes] -- [ ] [Documentation updated] - -## References -- [Read references/.md when .] +--- +name: {{.Name}} +description: {{.Description}} +{{- if .Model}} +model: {{.Model}} +{{- end}} +{{- if .Effort}} +effort: {{.Effort}} +{{- end}} +--- + +# {{.Title}} + +## Goal +[What problem this skill solves, in one paragraph.] + +## Default Operation Mode +[plan-first vs direct-implementation; what the user expects as the first deliverable.] + +## Collect Inputs First +- [Required context the agent must collect before editing files.] +- [Each input is a question that has to be answered.] + +## Execution Workflow + +### 1) [Phase 1 name] +[What to do, what to read, what to produce.] + +### 2) [Phase 2 name] +[...] + +## Gotchas +- [Non-obvious facts that defy reasonable assumptions.] +- [Each gotcha is a correction to a mistake the agent will otherwise make.] + +## Verification Before Reporting +- Run: `` +- Check: `` +- If blocked: report the blocked step explicitly, do not silently skip. + +## Completion Criteria +- [ ] [Concrete artifact 1 exists at ] +- [ ] [Validation 1 passes] +- [ ] [Documentation updated] + +## References +- [Read references/.md when .] diff --git a/internal/templater/templates/spec/bugfix.md.tmpl b/internal/templater/templates/spec/bugfix.md.tmpl index 6eba6fa..5e05892 100644 --- a/internal/templater/templates/spec/bugfix.md.tmpl +++ b/internal/templater/templates/spec/bugfix.md.tmpl @@ -1,31 +1,31 @@ -# Bugfix - -## Reproduction -- Steps: ... -- Versions: ... -- Logs/stack traces/payloads: ... - -## Behavior Contract - -Current Behavior: -WHEN [condition] -THEN the system [incorrect behavior] - -Expected Behavior: -WHEN [condition] -THE SYSTEM SHALL [correct behavior] - -Unchanged Behavior: -WHEN [condition] -THE SYSTEM SHALL CONTINUE TO [existing behavior that must not break] - -## Root Cause -[To be filled by the agent after investigation. No patches before this is documented.] - -## Regression Tests -- A test that fails before the fix and passes after. -- A non-regression test covering each "Unchanged Behavior" item. - -## Scope Discipline -- The PR MUST NOT include opportunistic refactors. -- Any incidental cleanup goes into a separate spec. +# Bugfix + +## Reproduction +- Steps: ... +- Versions: ... +- Logs/stack traces/payloads: ... + +## Behavior Contract + +Current Behavior: +WHEN [condition] +THEN the system [incorrect behavior] + +Expected Behavior: +WHEN [condition] +THE SYSTEM SHALL [correct behavior] + +Unchanged Behavior: +WHEN [condition] +THE SYSTEM SHALL CONTINUE TO [existing behavior that must not break] + +## Root Cause +[To be filled by the agent after investigation. No patches before this is documented.] + +## Regression Tests +- A test that fails before the fix and passes after. +- A non-regression test covering each "Unchanged Behavior" item. + +## Scope Discipline +- The PR MUST NOT include opportunistic refactors. +- Any incidental cleanup goes into a separate spec. diff --git a/internal/templater/templates/spec/design.md.tmpl b/internal/templater/templates/spec/design.md.tmpl index 71f638d..cdcc915 100644 --- a/internal/templater/templates/spec/design.md.tmpl +++ b/internal/templater/templates/spec/design.md.tmpl @@ -1,101 +1,101 @@ -# Design - -## Overview -- **Purpose**: ... -- **Users**: ... -- **Impact**: ... - -## Goals -- ... - -## Non-Goals -- ... - -## Existing Architecture Analysis -[How this fits into what exists. What is reused, what is new.] - -## Architecture Pattern & Boundary Map - -```mermaid -graph TD - Client[Client] --> API[API Layer] - API --> ServiceA - API --> ServiceB -``` - -## Technology Stack -| Layer | Choice | Role | Notes | -|---|---|---|---| -| Frontend | ... | UI | existing/new | -| Backend | ... | API | existing/new | -| Database | ... | persistence | existing/new | - -## File Structure Plan - -```text -src/ -├── domain/ -│ └── / # ServiceA boundary -└── api/ - └── / # API boundary -``` - -### Modified files -- ... - -## System Flows - -```mermaid -sequenceDiagram - Client->>API: - API->>ServiceA: - ServiceA-->>API: - API-->>Client: -``` - -## Requirements Traceability -| Requirement | Components | Interfaces | Flows | -|---|---|---|---| -| 1.1 | ServiceA | createX() | flow-1 | - -## Components and Interfaces - -### ServiceA -- **Intent**: ... -- **Requirements**: 1.1, 1.2 -- **Responsibilities**: ... -- **Dependencies**: - - Inbound: ... - - Outbound: ... - - External: ... -- **Contract**: - ``` - // language-appropriate contract definition - ``` - -## Error Handling & Resilience -- Retry policy: ... -- Timeouts: ... -- Idempotency: ... -- Failure modes and recovery: ... - -## Observability -- Logs: ... -- Metrics: ... -- Traces: ... - -## Security & Privacy -- Authentication/authorization considerations: ... -- Data classification: ... -- Audit trail: ... - -## Migration / Rollout / Rollback -- ... - -## Testing Strategy -- Unit: ... -- Integration: ... -- E2E: ... - -## Trade-offs and Rejected Alternatives -- ... +# Design + +## Overview +- **Purpose**: ... +- **Users**: ... +- **Impact**: ... + +## Goals +- ... + +## Non-Goals +- ... + +## Existing Architecture Analysis +[How this fits into what exists. What is reused, what is new.] + +## Architecture Pattern & Boundary Map + +```mermaid +graph TD + Client[Client] --> API[API Layer] + API --> ServiceA + API --> ServiceB +``` + +## Technology Stack +| Layer | Choice | Role | Notes | +|---|---|---|---| +| Frontend | ... | UI | existing/new | +| Backend | ... | API | existing/new | +| Database | ... | persistence | existing/new | + +## File Structure Plan + +```text +src/ +├── domain/ +│ └── / # ServiceA boundary +└── api/ + └── / # API boundary +``` + +### Modified files +- ... + +## System Flows + +```mermaid +sequenceDiagram + Client->>API: + API->>ServiceA: + ServiceA-->>API: + API-->>Client: +``` + +## Requirements Traceability +| Requirement | Components | Interfaces | Flows | +|---|---|---|---| +| 1.1 | ServiceA | createX() | flow-1 | + +## Components and Interfaces + +### ServiceA +- **Intent**: ... +- **Requirements**: 1.1, 1.2 +- **Responsibilities**: ... +- **Dependencies**: + - Inbound: ... + - Outbound: ... + - External: ... +- **Contract**: + ``` + // language-appropriate contract definition + ``` + +## Error Handling & Resilience +- Retry policy: ... +- Timeouts: ... +- Idempotency: ... +- Failure modes and recovery: ... + +## Observability +- Logs: ... +- Metrics: ... +- Traces: ... + +## Security & Privacy +- Authentication/authorization considerations: ... +- Data classification: ... +- Audit trail: ... + +## Migration / Rollout / Rollback +- ... + +## Testing Strategy +- Unit: ... +- Integration: ... +- E2E: ... + +## Trade-offs and Rejected Alternatives +- ... diff --git a/internal/templater/templates/spec/requirements.md.tmpl b/internal/templater/templates/spec/requirements.md.tmpl index 01d85e9..ccf7d49 100644 --- a/internal/templater/templates/spec/requirements.md.tmpl +++ b/internal/templater/templates/spec/requirements.md.tmpl @@ -1,29 +1,29 @@ -# Requirements - -## Introduction -[2-3 sentences on purpose and users.] - -## Requirements - -### Requirement 1: [Capability name] - -**Objective** -As a [persona], I want [capability], so that [benefit]. - -**Acceptance Criteria** -1. WHEN [event] THEN the system SHALL [response] within [latency/limit]. -2. IF [invalid trigger] THEN the system SHALL return error code `[CODE]` with HTTP [status]. -3. WHILE [state] THE SYSTEM SHALL [behavior]. -4. WHERE [feature flag / context] THE SYSTEM SHALL [behavior]. -5. THE SYSTEM SHALL [ubiquitous requirement, e.g., logging without sensitive data]. - - +# Requirements + +## Introduction +[2-3 sentences on purpose and users.] + +## Requirements + +### Requirement 1: [Capability name] + +**Objective** +As a [persona], I want [capability], so that [benefit]. + +**Acceptance Criteria** +1. WHEN [event] THEN the system SHALL [response] within [latency/limit]. +2. IF [invalid trigger] THEN the system SHALL return error code `[CODE]` with HTTP [status]. +3. WHILE [state] THE SYSTEM SHALL [behavior]. +4. WHERE [feature flag / context] THE SYSTEM SHALL [behavior]. +5. THE SYSTEM SHALL [ubiquitous requirement, e.g., logging without sensitive data]. + + diff --git a/internal/templater/templates/spec/research.md.tmpl b/internal/templater/templates/spec/research.md.tmpl index 0e03f3d..bb2da47 100644 --- a/internal/templater/templates/spec/research.md.tmpl +++ b/internal/templater/templates/spec/research.md.tmpl @@ -1,16 +1,16 @@ -# Research - -## Summary -- **Feature**: [feature-name] -- **Discovery Scope**: Greenfield | Brownfield extension -- **Key Findings**: - - ... - -## Research Log - -### Topic 1: -- **Sources Consulted**: [official docs, papers, OSS projects] -- **Findings**: - - ... -- **Implications**: - - [Every finding SHALL connect to a concrete design commitment downstream.] +# Research + +## Summary +- **Feature**: [feature-name] +- **Discovery Scope**: Greenfield | Brownfield extension +- **Key Findings**: + - ... + +## Research Log + +### Topic 1: +- **Sources Consulted**: [official docs, papers, OSS projects] +- **Findings**: + - ... +- **Implications**: + - [Every finding SHALL connect to a concrete design commitment downstream.] diff --git a/internal/templater/templates/spec/spec.json.tmpl b/internal/templater/templates/spec/spec.json.tmpl index 0ac498f..387202f 100644 --- a/internal/templater/templates/spec/spec.json.tmpl +++ b/internal/templater/templates/spec/spec.json.tmpl @@ -1,13 +1,13 @@ -{ - "feature_name": "{{.Feature}}", - "language": "{{.Language}}", - "phase": "initialized", - "development_flow": "{{.DevelopmentFlow}}", - "approvals": { - "requirements": { "generated": false, "approved": false }, - "design": { "generated": false, "approved": false }, - "tasks": { "generated": false, "approved": false } - }, - "ready_for_implementation": false, - "created_at": "{{.CreatedAt}}" -} +{ + "feature_name": "{{.Feature}}", + "language": "{{.Language}}", + "phase": "initialized", + "development_flow": "{{.DevelopmentFlow}}", + "approvals": { + "requirements": { "generated": false, "approved": false }, + "design": { "generated": false, "approved": false }, + "tasks": { "generated": false, "approved": false } + }, + "ready_for_implementation": false, + "created_at": "{{.CreatedAt}}" +} diff --git a/internal/templater/templates/steering-custom/custom.md.tmpl b/internal/templater/templates/steering-custom/custom.md.tmpl index a729261..0ef6d78 100644 --- a/internal/templater/templates/steering-custom/custom.md.tmpl +++ b/internal/templater/templates/steering-custom/custom.md.tmpl @@ -1,22 +1,22 @@ ---- -# Inclusion mode — choose exactly one. Keep only the keys that mode needs. -# always loaded on every interaction -# manual loaded only when the user references this file -# fileMatch loaded when touched files match fileMatchPattern (set the globs below) -# auto loaded when the description matches the task (set name + description below) -inclusion: always -# fileMatchPattern: ["src/api/**/*", "**/*Controller.*"] -# name: [kebab-name] -# description: [trigger sentence telling the agent when to load this] ---- - -# [Title] - -## Purpose -[Why this steering exists and what it constrains.] - -## Rules -- [Rule with rationale, not just an imperative.] - -## Examples -[Before/after snippets where they clarify the rule.] +--- +# Inclusion mode — choose exactly one. Keep only the keys that mode needs. +# always loaded on every interaction +# manual loaded only when the user references this file +# fileMatch loaded when touched files match fileMatchPattern (set the globs below) +# auto loaded when the description matches the task (set name + description below) +inclusion: always +# fileMatchPattern: ["src/api/**/*", "**/*Controller.*"] +# name: [kebab-name] +# description: [trigger sentence telling the agent when to load this] +--- + +# [Title] + +## Purpose +[Why this steering exists and what it constrains.] + +## Rules +- [Rule with rationale, not just an imperative.] + +## Examples +[Before/after snippets where they clarify the rule.] diff --git a/internal/templater/templates/steering/api-conventions.md.tmpl b/internal/templater/templates/steering/api-conventions.md.tmpl index ed755e3..13f8333 100644 --- a/internal/templater/templates/steering/api-conventions.md.tmpl +++ b/internal/templater/templates/steering/api-conventions.md.tmpl @@ -1,14 +1,14 @@ ---- -inclusion: fileMatch -fileMatchPattern: ["app/api/**/*", "src/api/**/*", "**/*Controller.*", "**/*Route.*"] ---- - -# API Conventions - -- Use plural resource names. -- Return standardized errors with code, message, and correlationId. -- Do not return sensitive data. -- Validate payloads before calling domain services. -- Separate controller/route from business rules. -- Include tests for success, validation, authorization, and external errors. -- Use pagination on lists. +--- +inclusion: fileMatch +fileMatchPattern: ["app/api/**/*", "src/api/**/*", "**/*Controller.*", "**/*Route.*"] +--- + +# API Conventions + +- Use plural resource names. +- Return standardized errors with code, message, and correlationId. +- Do not return sensitive data. +- Validate payloads before calling domain services. +- Separate controller/route from business rules. +- Include tests for success, validation, authorization, and external errors. +- Use pagination on lists. diff --git a/internal/templater/templates/steering/custom.md.tmpl b/internal/templater/templates/steering/custom.md.tmpl index 3dfb842..75050b2 100644 --- a/internal/templater/templates/steering/custom.md.tmpl +++ b/internal/templater/templates/steering/custom.md.tmpl @@ -1,17 +1,17 @@ ---- -inclusion: {{.Inclusion}}{{if eq .Inclusion "fileMatch"}} -fileMatchPattern: [{{range $i, $p := .Patterns}}{{if $i}}, {{end}}"{{$p}}"{{end}}]{{end}}{{if eq .Inclusion "auto"}} -name: {{.AutoName}} -description: {{.Description}}{{end}} ---- - -# {{.Title}} - -## Purpose -[Why this steering exists and what it constrains.] - -## Rules -- [Rule with rationale, not just an imperative.] - -## Examples -[Before/after snippets where they clarify the rule.] +--- +inclusion: {{.Inclusion}}{{if eq .Inclusion "fileMatch"}} +fileMatchPattern: [{{range $i, $p := .Patterns}}{{if $i}}, {{end}}"{{$p}}"{{end}}]{{end}}{{if eq .Inclusion "auto"}} +name: {{.AutoName}} +description: {{.Description}}{{end}} +--- + +# {{.Title}} + +## Purpose +[Why this steering exists and what it constrains.] + +## Rules +- [Rule with rationale, not just an imperative.] + +## Examples +[Before/after snippets where they clarify the rule.] diff --git a/internal/templater/templates/steering/observability.md.tmpl b/internal/templater/templates/steering/observability.md.tmpl index 7fc49b9..88d6303 100644 --- a/internal/templater/templates/steering/observability.md.tmpl +++ b/internal/templater/templates/steering/observability.md.tmpl @@ -1,20 +1,20 @@ ---- -inclusion: auto -name: observability -description: Logging, metrics, and tracing conventions. Use when adding or modifying instrumentation. ---- - -# Observability - -## Logging -- Use structured logging; never log secrets, tokens, or personal data. -- Include correlationId on every log line crossing a request boundary. -- Log at INFO for lifecycle events, WARN for recoverable issues, ERROR for failures requiring action. - -## Metrics -- Counter for events (`auth.login.failed`), histogram for durations, gauge for stateful values. -- Labels MUST NOT carry high-cardinality fields (userId, requestId). - -## Tracing -- Propagate trace context across async boundaries (jobs, queues, retries). -- Span names describe operations, not implementations. +--- +inclusion: auto +name: observability +description: Logging, metrics, and tracing conventions. Use when adding or modifying instrumentation. +--- + +# Observability + +## Logging +- Use structured logging; never log secrets, tokens, or personal data. +- Include correlationId on every log line crossing a request boundary. +- Log at INFO for lifecycle events, WARN for recoverable issues, ERROR for failures requiring action. + +## Metrics +- Counter for events (`auth.login.failed`), histogram for durations, gauge for stateful values. +- Labels MUST NOT carry high-cardinality fields (userId, requestId). + +## Tracing +- Propagate trace context across async boundaries (jobs, queues, retries). +- Span names describe operations, not implementations. diff --git a/internal/templater/templates/steering/product.md.tmpl b/internal/templater/templates/steering/product.md.tmpl index cafc159..2a2f50d 100644 --- a/internal/templater/templates/steering/product.md.tmpl +++ b/internal/templater/templates/steering/product.md.tmpl @@ -1,19 +1,19 @@ ---- -inclusion: always ---- - -# Product - -## Purpose -[One-paragraph description of what this product does and for whom.] - -## Core Capabilities -- [Capability 1] -- [Capability 2] -- [Capability 3] - -## Target Use Cases -[Primary scenarios the product serves.] - -## Value Proposition -[Why this product exists — the unique value it delivers.] +--- +inclusion: always +--- + +# Product + +## Purpose +[One-paragraph description of what this product does and for whom.] + +## Core Capabilities +- [Capability 1] +- [Capability 2] +- [Capability 3] + +## Target Use Cases +[Primary scenarios the product serves.] + +## Value Proposition +[Why this product exists — the unique value it delivers.] diff --git a/internal/templater/templates/steering/security.md.tmpl b/internal/templater/templates/steering/security.md.tmpl index 05dccb0..4ea8c7c 100644 --- a/internal/templater/templates/steering/security.md.tmpl +++ b/internal/templater/templates/steering/security.md.tmpl @@ -1,20 +1,20 @@ ---- -inclusion: always ---- - -# Security Guidelines - -## Principles -- Never log secrets, tokens, passwords, cookies, or personal data. -- Validate inputs at boundaries: API, queues, jobs, external integrations. -- Use least privilege for permissions and credentials. -- Prefer temporary and scoped credentials. -- Do not create authentication/authorization bypasses without explicit approval. - -## When modifying code -- Check the impact on authentication, authorization, and data exposure. -- Add tests for access denial and invalid inputs. -- Ensure that errors do not leak internal details. - -## Review -- In PRs with security impact, include a "Security considerations" section. +--- +inclusion: always +--- + +# Security Guidelines + +## Principles +- Never log secrets, tokens, passwords, cookies, or personal data. +- Validate inputs at boundaries: API, queues, jobs, external integrations. +- Use least privilege for permissions and credentials. +- Prefer temporary and scoped credentials. +- Do not create authentication/authorization bypasses without explicit approval. + +## When modifying code +- Check the impact on authentication, authorization, and data exposure. +- Add tests for access denial and invalid inputs. +- Ensure that errors do not leak internal details. + +## Review +- In PRs with security impact, include a "Security considerations" section. diff --git a/internal/templater/templates/steering/structure.md.tmpl b/internal/templater/templates/steering/structure.md.tmpl index 82299f4..c292a4e 100644 --- a/internal/templater/templates/steering/structure.md.tmpl +++ b/internal/templater/templates/steering/structure.md.tmpl @@ -1,24 +1,24 @@ ---- -inclusion: always ---- - -# Structure - -## Organization Philosophy -[How the codebase is organized and why.] - -## Directory Patterns -| Location | Purpose | Example | -|---|---|---| -| `src/domain/` | Business logic | `src/domain/orders/` | - -## Naming Conventions -- Files: [convention] -- Components: [convention] -- Functions: [convention] - -## Import Organization -[Path aliases, import order, etc.] - -## Code Organization Principles -[Principles for module boundaries, dependency direction, etc.] +--- +inclusion: always +--- + +# Structure + +## Organization Philosophy +[How the codebase is organized and why.] + +## Directory Patterns +| Location | Purpose | Example | +|---|---|---| +| `src/domain/` | Business logic | `src/domain/orders/` | + +## Naming Conventions +- Files: [convention] +- Components: [convention] +- Functions: [convention] + +## Import Organization +[Path aliases, import order, etc.] + +## Code Organization Principles +[Principles for module boundaries, dependency direction, etc.] diff --git a/internal/templater/templates/steering/tech.md.tmpl b/internal/templater/templates/steering/tech.md.tmpl index 99745bf..1290cef 100644 --- a/internal/templater/templates/steering/tech.md.tmpl +++ b/internal/templater/templates/steering/tech.md.tmpl @@ -1,24 +1,24 @@ ---- -inclusion: always ---- - -# Technology - -## Architecture Approach -[High-level pattern: monolith, microservices, event-driven, etc.] - -## Core Technologies -- Language: [name and version] -- Framework: [name and version] -- Runtime: [name and version] - -## Key Libraries -[Only major libraries that shape the codebase.] - -## Development Standards -- Type Safety: [policy] -- Code Quality: [linter, formatter] -- Testing: [framework, coverage targets] - -## Key Technical Decisions -[Architectural decisions with rationale — not exhaustive.] +--- +inclusion: always +--- + +# Technology + +## Architecture Approach +[High-level pattern: monolith, microservices, event-driven, etc.] + +## Core Technologies +- Language: [name and version] +- Framework: [name and version] +- Runtime: [name and version] + +## Key Libraries +[Only major libraries that shape the codebase.] + +## Development Standards +- Type Safety: [policy] +- Code Quality: [linter, formatter] +- Testing: [framework, coverage targets] + +## Key Technical Decisions +[Architectural decisions with rationale — not exhaustive.] diff --git a/internal/templater/templates/steering/testing.md.tmpl b/internal/templater/templates/steering/testing.md.tmpl index 7da4665..21b183c 100644 --- a/internal/templater/templates/steering/testing.md.tmpl +++ b/internal/templater/templates/steering/testing.md.tmpl @@ -1,20 +1,20 @@ ---- -inclusion: always ---- - -# Testing Standards - -## Test Pyramid -- Unit tests cover pure logic and invariants. -- Integration tests exercise real adapters (database, queue, external API stub). -- End-to-end tests cover golden paths and the top error cases per feature. - -## What every change SHALL have -- A test that fails before the change and passes after, when fixing a bug. -- Coverage for success, validation, authorization, and external failure paths on new endpoints. -- Non-regression tests for behavior explicitly listed as "MUST NOT change". - -## Conventions -- Test names describe the behavior, not the function under test. -- One assertion theme per test; multiple assertions only when they verify the same theme. -- Tests do not share mutable state across files. +--- +inclusion: always +--- + +# Testing Standards + +## Test Pyramid +- Unit tests cover pure logic and invariants. +- Integration tests exercise real adapters (database, queue, external API stub). +- End-to-end tests cover golden paths and the top error cases per feature. + +## What every change SHALL have +- A test that fails before the change and passes after, when fixing a bug. +- Coverage for success, validation, authorization, and external failure paths on new endpoints. +- Non-regression tests for behavior explicitly listed as "MUST NOT change". + +## Conventions +- Test names describe the behavior, not the function under test. +- One assertion theme per test; multiple assertions only when they verify the same theme. +- Tests do not share mutable state across files. diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go new file mode 100644 index 0000000..7b0a0d1 --- /dev/null +++ b/internal/textutil/textutil.go @@ -0,0 +1,27 @@ +// Package textutil holds tiny, dependency-free text helpers shared across the +// codebase. It exists so line-ending and BOM handling is defined in exactly one +// place: csdd runs on Windows/WSL where the working tree is frequently CRLF, and +// every content comparison (manifest hashes, frontmatter parsing, Markdown +// regexes) must treat "\r\n" and "\n" identically or the tool misbehaves. +package textutil + +import "strings" + +// bom is U+FEFF, which editors like Windows Notepad prepend to files. It is not +// Unicode whitespace, so it must be stripped explicitly. Built from its code +// point so the byte sequence never appears literally in this source file. +var bom = string(rune(0xFEFF)) + +// NormalizeNewlines converts Windows (\r\n) and classic-Mac (lone \r) line +// endings to Unix (\n) and strips a leading UTF-8 BOM. The result is what every +// parser and content hash in csdd should operate on so behaviour is identical +// regardless of how git checked the files out. +func NormalizeNewlines(s string) string { + s = strings.TrimPrefix(s, bom) + if strings.IndexByte(s, '\r') == -1 { + return s + } + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return s +} diff --git a/internal/textutil/textutil_test.go b/internal/textutil/textutil_test.go new file mode 100644 index 0000000..deecc99 --- /dev/null +++ b/internal/textutil/textutil_test.go @@ -0,0 +1,28 @@ +package textutil + +import "testing" + +func TestNormalizeNewlines(t *testing.T) { + bom := string(rune(0xFEFF)) // UTF-8 BOM, built here to keep the source ASCII + cases := []struct { + name string + in string + want string + }{ + {"lf unchanged", "a\nb\n", "a\nb\n"}, + {"crlf", "a\r\nb\r\n", "a\nb\n"}, + {"lone cr", "a\rb\r", "a\nb\n"}, + {"mixed", "a\r\nb\nc\r", "a\nb\nc\n"}, + {"bom stripped", bom + "---\n", "---\n"}, + {"bom plus crlf", bom + "a\r\nb", "a\nb"}, + {"empty", "", ""}, + {"bom mid-string kept", "a" + bom + "b", "a" + bom + "b"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := NormalizeNewlines(c.in); got != c.want { + t.Errorf("NormalizeNewlines(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} diff --git a/internal/tui/browser.go b/internal/tui/browser.go index 17970d8..9e1aae4 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -26,14 +26,43 @@ type browserModel struct { items []browserItem cursor int err string + + // previewPath/previewRaw cache the currently-selected file's contents so the + // render path (View) never touches disk. The file is read once, in Update, + // when the selection changes — critical on WSL's /mnt/c 9p mount where a + // per-frame os.ReadFile made scrolling visibly janky. + previewPath string + previewRaw string + previewErr bool } func newBrowser(root string) browserModel { b := browserModel{root: root} b.refresh() + b.loadPreview() return b } +// loadPreview reads the currently-selected item's file into the cache. It is a +// no-op when the cached path already matches, so repeated calls are cheap. +func (b *browserModel) loadPreview() { + if b.cursor < 0 || b.cursor >= len(b.items) { + b.previewPath, b.previewRaw, b.previewErr = "", "", false + return + } + path := b.items[b.cursor].path + if path == b.previewPath && !b.previewErr { + return + } + data, err := os.ReadFile(path) + b.previewPath = path + if err != nil { + b.previewRaw, b.previewErr = "could not read "+path, true + return + } + b.previewRaw, b.previewErr = string(data), false +} + func (b *browserModel) refresh() { b.items = nil b.err = "" @@ -149,13 +178,16 @@ func (b browserModel) Update(msg tea.Msg) (browserModel, tea.Cmd) { case "up", "k": if b.cursor > 0 { b.cursor-- + b.loadPreview() } case "down", "j": if b.cursor < len(b.items)-1 { b.cursor++ + b.loadPreview() } case "r": b.refresh() + b.loadPreview() } return b, nil } @@ -201,7 +233,7 @@ func (b browserModel) View(width, height int) string { list.WriteString(prefix + line + "\n") } - preview := previewText(b.items[b.cursor].path, previewW, bodyH-2) + preview := b.previewText(previewW, bodyH-2) listBox := Styles.Box.Width(listW).Height(bodyH).Render(list.String()) previewBox := Styles.Box.Width(previewW).Height(bodyH).Render( Styles.Heading.Render(b.items[b.cursor].label) + "\n\n" + preview, @@ -210,12 +242,13 @@ func (b browserModel) View(width, height int) string { return lipgloss.JoinHorizontal(lipgloss.Top, listBox, " ", previewBox) + "\n" + hint } -func previewText(path string, width, height int) string { - data, err := os.ReadFile(path) - if err != nil { - return Styles.Err.Render("could not read " + path) +// previewText formats the cached file contents to fit the preview pane. It does +// no IO — the content was loaded in loadPreview when the selection last changed. +func (b browserModel) previewText(width, height int) string { + if b.previewErr { + return Styles.Err.Render(b.previewRaw) } - lines := strings.Split(string(data), "\n") + lines := strings.Split(b.previewRaw, "\n") if len(lines) > height { lines = lines[:height] lines = append(lines, Styles.Dim.Render("…")) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b177480..d21d5a0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -186,6 +186,3 @@ type resultMsg struct { func cmdBack() tea.Cmd { return func() tea.Msg { return backToMenuMsg{} } } func cmdOpen(kind wizardKind) tea.Cmd { return func() tea.Msg { return openWizardMsg{kind} } } func cmdBrowser() tea.Cmd { return func() tea.Msg { return openBrowserMsg{} } } -func cmdResult(text string, isErr bool) tea.Cmd { - return func() tea.Msg { return resultMsg{text: text, isErr: isErr} } -} diff --git a/internal/tui/ui_test.go b/internal/tui/ui_test.go index dcc77c4..09c53d9 100644 --- a/internal/tui/ui_test.go +++ b/internal/tui/ui_test.go @@ -104,7 +104,9 @@ func TestPreviewTextRuneSafe(t *testing.T) { if err := os.WriteFile(p, []byte("αααααααα"), 0o644); err != nil { t.Fatal(err) } - out := previewText(p, 5, 10) + b := browserModel{items: []browserItem{{path: p}}} + b.loadPreview() + out := b.previewText(5, 10) if !strings.Contains(out, "…") { t.Errorf("wide line should be truncated with an ellipsis: %q", out) } @@ -116,7 +118,9 @@ func TestPreviewTextRuneSafe(t *testing.T) { } func TestPreviewTextMissingFile(t *testing.T) { - out := previewText(filepath.Join(t.TempDir(), "nope.md"), 40, 10) + b := browserModel{items: []browserItem{{path: filepath.Join(t.TempDir(), "nope.md")}}} + b.loadPreview() + out := b.previewText(40, 10) if !strings.Contains(out, "could not read") { t.Errorf("missing file preview should report the error: %q", out) } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index ba66ef7..4de7d2f 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -10,28 +10,71 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "github.com/protonspy/csdd/internal/frontmatter" + "github.com/protonspy/csdd/internal/textutil" ) var ( - reqHeader = regexp.MustCompile(`(?m)^###\s+Requirement\s+(\d+)\b`) - criterion = regexp.MustCompile(`(?mi)^(\s*)(\d+)\.\s+(WHEN|WHILE|IF|WHERE|THE\s+SYSTEM)\b`) - criterionNumber = regexp.MustCompile(`(?m)^\s*(\d+)\.\s+`) + reqHeader = regexp.MustCompile(`(?m)^###\s+Requirement\s+(\d+)\b`) + // criterionLine matches a numbered list item; group 1 is the list number and + // group 2 the remaining text. Whether it counts as an acceptance criterion is + // decided by earsLine on group 2, so extraction and the EARS check agree. + criterionLine = regexp.MustCompile(`^\s*(\d+)\.\s+(.*)$`) earsLine = regexp.MustCompile(`(?i)\b(WHEN|WHILE|IF|WHERE)\b.*\bTHE\s+SYSTEM\s+SHALL\b|\bTHE\s+SYSTEM\s+SHALL\b`) shouldWord = regexp.MustCompile(`(?i)\bshould\b`) numberedLine = regexp.MustCompile(`^\d+\.\s`) - taskLine = regexp.MustCompile(`^(\s*)-\s+\[\s*[xX ]?\s*\]\s+(\d+(?:\.\d+)?)\.?\s+(.*)$`) - reqAnnot = regexp.MustCompile(`_Requirements:\s*([\d,\.\s]+)_`) - boundaryAnnot = regexp.MustCompile(`_Boundary:\s*([A-Za-z0-9_\-]+)_`) - dependsAnnot = regexp.MustCompile(`_Depends:\s*([\d,\.\s]+)_`) - parallelMarker = regexp.MustCompile(`\(P\)`) componentHeader = regexp.MustCompile(`(?m)^###\s+([A-Za-z0-9_\-]+)\s*$`) + componentsHead = regexp.MustCompile(`(?m)^##\s+Components and Interfaces\s*$`) + nextH2 = regexp.MustCompile(`(?m)^##\s`) traceabilityRow = regexp.MustCompile(`(?m)^\|\s*(\d+\.\d+)\s*\|`) numericID = regexp.MustCompile(`^\d+\.\d+$`) + + // Canonical task grammar. It is exported and reused by internal/session so the + // validator (correctness) and the dashboard (display) can never disagree on + // what a task line or annotation is. Group indices: 1=indent, 2=checkbox + // state, 3=id (any depth: 1, 1.2, 1.2.3), 4=title. Boundary names share the + // component-header charset — a boundary must name a real component. + TaskLineRe = regexp.MustCompile(`^(\s*)-\s+\[\s*([xX ]?)\s*\]\s+(\d+(?:\.\d+)*)\.?\s+(.*)$`) + ReqAnnotRe = regexp.MustCompile(`_Requirements:\s*([\d,\.\s]+)_`) + BoundaryAnnotRe = regexp.MustCompile(`_Boundary:\s*([A-Za-z0-9_\-]+)_`) + DependsAnnotRe = regexp.MustCompile(`_Depends:\s*([\d,\.\s]+)_`) + ParallelRe = regexp.MustCompile(`\(P\)`) ) +// MaskCodeFences is the exported entry point to fence masking, shared with +// internal/session so the dashboard's task stats ignore fenced examples exactly +// as the validator's checks do. +func MaskCodeFences(text string) string { return maskCodeFences(text) } + +// maskCodeFences blanks every line inside a fenced code block (``` or ~~~) while +// preserving the total line count, so line-oriented regexes never mistake a +// fenced example ("- [ ] 1. …" in a doc, a numbered list in prose) for a real +// task or acceptance criterion. Line numbers in reported issues stay accurate. +func maskCodeFences(text string) string { + lines := strings.Split(text, "\n") + inFence := false + fence := "" + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !inFence { + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = true + fence = trimmed[:3] + lines[i] = "" + } + continue + } + if strings.HasPrefix(trimmed, fence) { + inFence = false + } + lines[i] = "" + } + return strings.Join(lines, "\n") +} + // Issue represents a single validation problem. type Issue struct { File string @@ -87,10 +130,11 @@ func ValidateSpec(specDir string, phase Phase) []Issue { // Requirements if wantReq { - if text, err := os.ReadFile(reqPath); err == nil { - reqIDs = extractRequirementIDs(string(text)) - issues = append(issues, checkEARS("requirements.md", string(text))...) - headers := reqHeader.FindAllStringSubmatch(string(text), -1) + if raw, err := os.ReadFile(reqPath); err == nil { + text := maskCodeFences(textutil.NormalizeNewlines(string(raw))) + reqIDs = extractRequirementIDs(text) + issues = append(issues, checkEARS("requirements.md", text)...) + headers := reqHeader.FindAllStringSubmatch(text, -1) seen := map[string]int{} for _, h := range headers { seen[h[1]]++ @@ -100,7 +144,7 @@ func ValidateSpec(specDir string, phase Phase) []Issue { issues = append(issues, Issue{File: "requirements.md", Msg: fmt.Sprintf("duplicate Requirement %s header", k)}) } } - issues = append(issues, checkDuplicateCriterionIDs(string(text))...) + issues = append(issues, checkDuplicateCriterionIDs(text)...) } else if phase != PhaseAll { if _, err := os.Stat(bugfixPath); err != nil { issues = append(issues, Issue{File: "requirements.md", Msg: "missing (or bugfix.md)"}) @@ -110,7 +154,7 @@ func ValidateSpec(specDir string, phase Phase) []Issue { // Bugfix if data, err := os.ReadFile(bugfixPath); err == nil { - text := string(data) + text := textutil.NormalizeNewlines(string(data)) for _, needle := range []string{"Current Behavior:", "Expected Behavior:", "Unchanged Behavior:"} { if !strings.Contains(text, needle) { issues = append(issues, Issue{File: "bugfix.md", Msg: "missing '" + needle + "' section"}) @@ -124,7 +168,8 @@ func ValidateSpec(specDir string, phase Phase) []Issue { // Design if wantDesign { if data, err := os.ReadFile(designPath); err == nil { - text := string(data) + text := textutil.NormalizeNewlines(string(data)) + masked := maskCodeFences(text) lineCount := strings.Count(text, "\n") + 1 if lineCount > 1000 { issues = append(issues, Issue{ @@ -132,8 +177,8 @@ func ValidateSpec(specDir string, phase Phase) []Issue { Msg: fmt.Sprintf("%d lines > 1000 — split the feature into multiple specs", lineCount), }) } - components = extractComponents(text) - traceability = extractTraceability(text) + components = extractComponents(masked) + traceability = extractTraceability(masked) if !strings.Contains(text, "## File Structure Plan") { issues = append(issues, Issue{File: "design.md", Msg: "missing '## File Structure Plan' section"}) } @@ -162,7 +207,7 @@ func ValidateSpec(specDir string, phase Phase) []Issue { // Tasks if wantTasks { if data, err := os.ReadFile(tasksPath); err == nil { - tasks := parseTasks(string(data)) + tasks := parseTasks(maskCodeFences(textutil.NormalizeNewlines(string(data)))) ids := map[string]struct{}{} boundaries := map[string]string{} firstLine := map[string]int{} @@ -276,31 +321,50 @@ func ValidateSpec(specDir string, phase Phase) []Issue { return issues } +// scannedCriterion is one acceptance criterion discovered by scanCriteria. +type scannedCriterion struct { + id string // "." + lineNo int // 1-based line in the (masked) text +} + +// scanCriteria walks the text line by line, tracking the current Requirement +// header, and returns every numbered list item that carries EARS structure. Both +// ID extraction and duplicate detection use this single pass, so a criterion the +// EARS check accepts always yields exactly one ID (no anchoring mismatch), an +// incidental numbered note (no EARS keywords) is never mistaken for a criterion, +// and reported line numbers point at the criterion itself. +func scanCriteria(text string) []scannedCriterion { + var out []scannedCriterion + curReq := "" + for i, line := range strings.Split(text, "\n") { + if hm := reqHeader.FindStringSubmatch(line); hm != nil { + curReq = hm[1] + continue + } + if curReq == "" { + continue + } + m := criterionLine.FindStringSubmatch(line) + if m == nil || !earsLine.MatchString(m[2]) { + continue + } + out = append(out, scannedCriterion{id: curReq + "." + m[1], lineNo: i + 1}) + } + return out +} + func checkDuplicateCriterionIDs(text string) []Issue { var issues []Issue - headers := reqHeader.FindAllStringSubmatchIndex(text, -1) - for i, h := range headers { - reqN := text[h[2]:h[3]] - start := h[1] - end := len(text) - if i+1 < len(headers) { - end = headers[i+1][0] - } - block := text[start:end] - seen := map[string]int{} - for _, m := range criterionNumber.FindAllStringSubmatchIndex(block, -1) { - criterionN := block[m[2]:m[3]] - id := reqN + "." + criterionN - line := strings.Count(text[:start+m[0]], "\n") + 1 - if prev, ok := seen[id]; ok { - issues = append(issues, Issue{ - File: "requirements.md", - Line: line, - Msg: fmt.Sprintf("duplicate acceptance criterion ID %s (first seen on line %d)", id, prev), - }) - } else { - seen[id] = line - } + seen := map[string]int{} + for _, c := range scanCriteria(text) { + if prev, ok := seen[c.id]; ok { + issues = append(issues, Issue{ + File: "requirements.md", + Line: c.lineNo, + Msg: fmt.Sprintf("duplicate acceptance criterion ID %s (first seen on line %d)", c.id, prev), + }) + } else { + seen[c.id] = c.lineNo } } return issues @@ -308,28 +372,23 @@ func checkDuplicateCriterionIDs(text string) []Issue { func extractRequirementIDs(text string) map[string]struct{} { out := map[string]struct{}{} - headers := reqHeader.FindAllStringSubmatchIndex(text, -1) - for i, m := range headers { - reqN := text[m[2]:m[3]] - start := m[1] - end := len(text) - if i+1 < len(headers) { - end = headers[i+1][0] - } - block := text[start:end] - for _, cm := range criterion.FindAllStringSubmatch(block, -1) { - out[reqN+"."+cm[2]] = struct{}{} - } + for _, c := range scanCriteria(text) { + out[c.id] = struct{}{} } return out } func extractComponents(text string) map[string]struct{} { - idx := strings.Index(text, "## Components and Interfaces") - if idx < 0 { + loc := componentsHead.FindStringIndex(text) + if loc == nil { return nil } - section := text[idx:] + section := text[loc[1]:] + // Stop at the next top-level "## " heading so component "###" headers don't + // leak in from later sections (Testing Strategy, Error Handling, …). + if end := nextH2.FindStringIndex(section); end != nil { + section = section[:end[0]] + } out := map[string]struct{}{} for _, m := range componentHeader.FindAllStringSubmatch(section, -1) { out[m[1]] = struct{}{} @@ -389,38 +448,38 @@ func parseTasks(text string) []taskRecord { return } joined := strings.Join(block, "\n") - if m := reqAnnot.FindStringSubmatch(joined); m != nil { + if m := ReqAnnotRe.FindStringSubmatch(joined); m != nil { for _, tok := range strings.Split(m[1], ",") { if t := strings.TrimSpace(tok); t != "" { cur.requirements = append(cur.requirements, t) } } } - if m := dependsAnnot.FindStringSubmatch(joined); m != nil { + if m := DependsAnnotRe.FindStringSubmatch(joined); m != nil { for _, tok := range strings.Split(m[1], ",") { if t := strings.TrimSpace(tok); t != "" { cur.depends = append(cur.depends, t) } } } - if m := boundaryAnnot.FindStringSubmatch(joined); m != nil { + if m := BoundaryAnnotRe.FindStringSubmatch(joined); m != nil { cur.boundary = m[1] } - if parallelMarker.MatchString(joined) { + if ParallelRe.MatchString(joined) { cur.parallel = true } out = append(out, *cur) cur = nil } for i, line := range strings.Split(text, "\n") { - if m := taskLine.FindStringSubmatch(line); m != nil { + if m := TaskLineRe.FindStringSubmatch(line); m != nil { flush() block = []string{line} - cur = &taskRecord{id: m[2], lineNo: i + 1} - if parallelMarker.MatchString(line) { + cur = &taskRecord{id: m[3], lineNo: i + 1} + if ParallelRe.MatchString(line) { cur.parallel = true } - if bm := boundaryAnnot.FindStringSubmatch(line); bm != nil { + if bm := BoundaryAnnotRe.FindStringSubmatch(line); bm != nil { cur.boundary = bm[1] } continue @@ -445,12 +504,7 @@ func truncate(s string, n int) string { func sortedJoin(in []string) string { cp := append([]string(nil), in...) - // Simple insertion sort: small slices. - for i := 1; i < len(cp); i++ { - for j := i; j > 0 && cp[j-1] > cp[j]; j-- { - cp[j-1], cp[j] = cp[j], cp[j-1] - } - } + sort.Strings(cp) return strings.Join(cp, ", ") } @@ -528,7 +582,7 @@ func ValidateSkill(skillDir, name string) ([]Issue, int, int) { if err != nil { return []Issue{{File: filepath.Base(skillDir), Msg: "SKILL.md missing"}}, 0, 0 } - text := string(data) + text := textutil.NormalizeNewlines(string(data)) fm := frontmatter.Parse(text) if fm.AsString("name", "") == "" { issues = append(issues, Issue{File: "SKILL.md", Msg: "frontmatter missing 'name'"}) diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go index 49622d4..a316c94 100644 --- a/internal/validator/validator_test.go +++ b/internal/validator/validator_test.go @@ -591,3 +591,103 @@ func TestIssueStringFormat(t *testing.T) { t.Errorf("no-line format wrong: %s", noLine) } } + +// --- Regression tests for grammar-unification fixes --- + +func TestExtractRequirementIDsMidLineEARSKeyword(t *testing.T) { + // A criterion whose EARS keyword is not immediately after the number must + // still register an ID, or tasks referencing it get false "unknown requirement". + req := "### Requirement 1: Boot\n\n" + + "1. On startup, WHEN the app boots THE SYSTEM SHALL load config.\n" + ids := extractRequirementIDs(req) + if _, ok := ids["1.1"]; !ok { + t.Errorf("mid-line EARS keyword criterion not registered as 1.1: got %v", ids) + } +} + +func TestExtractRequirementIDsIgnoresNonEARSNotes(t *testing.T) { + req := "### Requirement 1: Boot\n\n" + + "1. WHEN x THE SYSTEM SHALL y.\n\n" + + "Notes:\n1. just a note, not a criterion\n" + ids := extractRequirementIDs(req) + if _, ok := ids["1.1"]; !ok { + t.Errorf("real criterion 1.1 missing: %v", ids) + } + // The note reuses list number 1 but has no EARS structure, so it must not + // create a phantom duplicate criterion. + if dups := checkDuplicateCriterionIDs(req); len(dups) != 0 { + t.Errorf("non-EARS numbered note caused false duplicate: %v", dups) + } +} + +func TestExtractComponentsStopsAtNextSection(t *testing.T) { + design := "## Components and Interfaces\n\n" + + "### AlbumService\n- intent\n\n" + + "## Testing Strategy\n\n" + + "### UnitTests\n- cases\n" + comps := extractComponents(design) + if _, ok := comps["AlbumService"]; !ok { + t.Errorf("AlbumService component missing: %v", comps) + } + if _, ok := comps["UnitTests"]; ok { + t.Errorf("UnitTests from a later section leaked into components: %v", comps) + } +} + +func TestParseTasksThreeLevelID(t *testing.T) { + tasks := "## Phase 1\n- [ ] 1. Parent\n - [ ] 1.1 Child\n - [ ] 1.1.1 Grandchild\n" + got := parseTasks(tasks) + var ids []string + for _, tk := range got { + ids = append(ids, tk.id) + } + found := false + for _, id := range ids { + if id == "1.1.1" { + found = true + } + } + if !found { + t.Errorf("three-level task id 1.1.1 not parsed; got ids %v", ids) + } +} + +func TestValidatorIgnoresFencedTaskExamples(t *testing.T) { + // A fenced example must not be counted as a real task or trip duplicate-ID. + tasks := "## Phase 1\n- [ ] 1. Real _Boundary: Svc_\n" + + " - _Requirements: 1.1_\n\n" + + "```\n- [ ] 1. Fenced example\n```\n" + masked := MaskCodeFences(tasks) + got := parseTasks(masked) + if len(got) != 1 { + t.Errorf("expected 1 real task, fenced example was counted: got %d", len(got)) + } +} + +func TestDuplicateCriterionLineNumberAccurate(t *testing.T) { + req := "### Requirement 1: R\n" + // line 1 + "1. WHEN a THE SYSTEM SHALL b.\n" + // line 2 -> 1.1 first + "1. WHEN c THE SYSTEM SHALL d.\n" // line 3 -> 1.1 dup + dups := checkDuplicateCriterionIDs(req) + if len(dups) != 1 { + t.Fatalf("expected 1 duplicate, got %d: %v", len(dups), dups) + } + if dups[0].Line != 3 { + t.Errorf("duplicate reported on line %d, want 3", dups[0].Line) + } + if !strings.Contains(dups[0].Msg, "first seen on line 2") { + t.Errorf("first-seen line wrong: %q", dups[0].Msg) + } +} + +func TestValidateSpecCRLFRequirements(t *testing.T) { + // CRLF requirements must validate identically to LF. + crlf := strings.ReplaceAll(validRequirements, "\n", "\r\n") + dir := writeSpec(t, map[string]string{"requirements.md": crlf}) + issues := ValidateSpec(dir, PhaseRequirements) + for _, is := range issues { + if strings.Contains(is.Msg, "EARS") { + t.Errorf("CRLF requirements produced a false EARS issue: %v", is) + } + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index bb9b84d..3d90df1 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -120,6 +120,10 @@ func requireHost(next http.Handler, allowed ...string) http.Handler { } } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if extra["*"] { + next.ServeHTTP(w, r) // wildcard bind: caller opted into any-Host access + return + } h := hostname(r.Host) if !alwaysAllowedHosts[h] && !extra[h] { http.Error(w, "forbidden: unrecognised Host header", http.StatusForbidden) @@ -129,6 +133,16 @@ func requireHost(next http.Handler, allowed ...string) http.Handler { }) } +// isWildcardHost reports whether addr is an all-interfaces bind address, for +// which the Host-header guard is relaxed (see serve). +func isWildcardHost(addr string) bool { + switch strings.TrimSpace(addr) { + case "", "0.0.0.0", "::", "[::]": + return true + } + return false +} + // hostname returns the lowercased host portion of a Host header, stripped of any // port and IPv6 brackets (so "[::1]:7777" and "[::1]" both become "::1"). func hostname(hostHeader string) string { diff --git a/internal/web/server.go b/internal/web/server.go index b8f526e..3bb22cf 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -119,8 +119,16 @@ func serve(ctx context.Context, ln net.Listener, opts Options, a *auth, publicUR // Host allowlist for the DNS-rebinding guard: the loopback names (always // allowed inside newMux) plus the bound host and, when tunnelling, the public // tunnel hostname — otherwise legitimate requests through the tunnel would be - // rejected with 403. - allowedHosts := []string{opts.Host} + // rejected with 403. When the user deliberately binds a wildcard address + // (0.0.0.0 / ::), they have opted into all-interfaces LAN access, so pin the + // guard open ("*"): rebinding protection only matters for the default loopback + // bind, and a fixed allowlist would 403 every LAN client's own IP Host header. + var allowedHosts []string + if isWildcardHost(opts.Host) { + allowedHosts = []string{"*"} + } else { + allowedHosts = []string{opts.Host} + } if publicURL != "" { if u, err := url.Parse(publicURL); err == nil && u.Hostname() != "" { allowedHosts = append(allowedHosts, u.Hostname()) diff --git a/internal/web/sse.go b/internal/web/sse.go index 4c876ae..54da6f3 100644 --- a/internal/web/sse.go +++ b/internal/web/sse.go @@ -134,14 +134,14 @@ func (h *hub) sseHandler(w http.ResponseWriter, r *http.Request) { } writeEvent(w, flusher, v) case <-keepalive.C: - fmt.Fprint(w, ": ping\n\n") + _, _ = fmt.Fprint(w, ": ping\n\n") flusher.Flush() } } } func writeEvent(w http.ResponseWriter, flusher http.Flusher, version int) { - fmt.Fprintf(w, "event: change\ndata: {\"version\":%d}\n\n", version) + _, _ = fmt.Fprintf(w, "event: change\ndata: {\"version\":%d}\n\n", version) flusher.Flush() } diff --git a/internal/web/tunnel.go b/internal/web/tunnel.go index f231c72..a5586b2 100644 --- a/internal/web/tunnel.go +++ b/internal/web/tunnel.go @@ -116,7 +116,7 @@ func requestTunnel(ctx context.Context, subdomain string) (tunnelInfo, error) { if err != nil { return info, fmt.Errorf("contact localtunnel: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return info, fmt.Errorf("localtunnel returned %s", resp.Status) } @@ -148,18 +148,18 @@ func tunnelWorker(ctx context.Context, remotePort, localPort int) { first := make([]byte, 1) n, rerr := remote.Read(first) if rerr != nil { - remote.Close() + _ = remote.Close() continue // idle pooled connection dropped; reconnect promptly } local, lerr := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", localPort)) if lerr != nil { - remote.Close() + _ = remote.Close() tunnelSleep(ctx, time.Second) continue } if _, werr := local.Write(first[:n]); werr != nil { - remote.Close() - local.Close() + _ = remote.Close() + _ = local.Close() continue } pipe(remote, local) @@ -172,8 +172,8 @@ func pipe(a, b net.Conn) { go func() { _, _ = io.Copy(a, b); done <- struct{}{} }() go func() { _, _ = io.Copy(b, a); done <- struct{}{} }() <-done - a.Close() - b.Close() + _ = a.Close() + _ = b.Close() } // tunnelPassword fetches the value loca.lt's "Tunnel website ahead" interstitial @@ -189,7 +189,7 @@ func tunnelPassword(ctx context.Context) string { if err != nil { return "" } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "" } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 2e94637..bc160c0 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "github.com/protonspy/csdd/internal/paths" ) @@ -32,6 +33,52 @@ func KebabCheck(name, kind string) error { return nil } +// SafeName rejects a positional artifact name that could escape its parent +// directory when joined onto a workspace path. Every delete/show/generate/approve +// lookup MUST call this before filepath.Join, because those names arrive from CLI +// args and may be malformed or hostile — without it, `csdd spec delete .. --force` +// resolves to the workspace root and RemoveAll deletes the whole project. It +// rejects path separators, "." / "..", absolute paths, and (on Windows) reserved +// device names via filepath.IsLocal. +func SafeName(name, kind string) error { + if name == "" { + return fmt.Errorf("%s name is required", kind) + } + if strings.ContainsAny(name, `/\`) || name == "." || name == ".." || !filepath.IsLocal(name) { + return fmt.Errorf("invalid %s name %q: must be a single path segment with no separators or '..'", kind, name) + } + return nil +} + +// AtomicWrite writes data to a temp file in the same directory as path and +// renames it into place, so a crash or a concurrent reader (e.g. the web +// dashboard polling spec.json) never observes a truncated or half-written file. +// The temp file is created in the destination directory so the rename stays on +// one filesystem. +func AtomicWrite(path string, data []byte, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + // Find walks upward from start looking for the nearest .claude/ directory. // Returns the start directory unchanged when no workspace is found. func Find(start string) string { @@ -112,10 +159,10 @@ func SafeWrite(path, content string) (bool, error) { if _, err := os.Stat(path); err == nil { return false, nil } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := AtomicWrite(path, []byte(content), 0o644); err != nil { return false, err } - return true, os.WriteFile(path, []byte(content), 0o644) + return true, nil } // WriteFile writes content unconditionally unless the path exists and overwrite is false. @@ -125,10 +172,7 @@ func WriteFile(path, content string, overwrite bool) error { return fmt.Errorf("refusing to overwrite existing file: %s (pass --force)", path) } } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - return os.WriteFile(path, []byte(content), 0o644) + return AtomicWrite(path, []byte(content), 0o644) } // Relative reports the path of target relative to root (for friendly logging). diff --git a/mcp-server/README.md b/mcp-server/README.md index 01ea8d6..7d9b2c9 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -1,232 +1,232 @@ -# @protonspy/csdd-mcp - -**An [MCP](https://modelcontextprotocol.io) server that exposes the [`csdd`](https://github.com/protonspy/csdd) CLI as tools — one tool per subcommand.** - -`csdd` governs the Claude Code Spec-Driven Development workflow (steering, specs, -skills, sub-agents, MCP servers) and validates the contract mechanically. This -server lets an MCP-capable agent drive `csdd` **directly as tools**, instead of -shelling out to a terminal — same operations, same phase gates, same exit codes. - -``` -agent ──(MCP/stdio)──▶ csdd-mcp ──(execFile)──▶ csdd binary ──▶ .claude/ · specs/ -``` - -Each tool builds a `csdd` argv, runs the binary headlessly (`NO_COLOR=1`, no TTY -so confirmations auto-decline), and returns its `stdout`/`stderr`. A non-zero -exit becomes an MCP error result; **exit `2` (validation failure) is surfaced -distinctly** so the agent can tell "your spec is invalid" from "the command -broke". - ---- - -## Requirements - -- **Node.js ≥ 18** (the published package ships compiled JS). -- **The `csdd` binary**, reachable by the server — see [Locating the csdd binary](#locating-the-csdd-binary). - ---- - -## Install & configure - -The server runs over **stdio**; point your MCP client at it. - -### Claude Code - -```bash -# project scope (writes .mcp.json) — or use --scope user for all projects -claude mcp add csdd -- npx -y @protonspy/csdd-mcp -``` - -Or add it to `.mcp.json` by hand: - -```json -{ - "mcpServers": { - "csdd": { - "command": "npx", - "args": ["-y", "@protonspy/csdd-mcp"], - "env": { "CSDD_BIN": "/usr/local/bin/csdd" } - } - } -} -``` - -> `env.CSDD_BIN` is optional — drop it if `csdd` is on your `PATH`. See below. - -### Any MCP client - -Launch `npx -y @protonspy/csdd-mcp` (or `csdd-mcp` if installed globally) as a -stdio server. The process stays alive serving stdio until the client closes the -pipe. - ---- - -## Locating the csdd binary - -The server resolves `csdd` once, on first use, in this order (first hit wins): - -| # | Source | When it applies | -|---|--------|-----------------| -| 1 | **`$CSDD_BIN`** | Explicit absolute path. Always wins — use this if in doubt. | -| 2 | **Platform package** `@protonspy/csdd--` | Declared as an `optionalDependency` of this package, so `npx`/`npm i` fetches the prebuilt binary for your OS/arch automatically — the zero-config path. | -| 3 | **Sibling repo binary** (`../csdd`, `../../csdd`) | When running from a checkout of the csdd repo. | -| 4 | **`csdd` on `$PATH`** | Last resort, resolved by the OS at spawn time. | - -If none resolve, calls fail with **exit `127`** and a message telling you to set -`CSDD_BIN`, install `@protonspy/csdd`, or put `csdd` on your `PATH`. - -> **Zero-config:** running via `npx -y @protonspy/csdd-mcp` pulls the matching -> binary through #2 automatically — nothing to install. Set `CSDD_BIN` only to -> pin a specific build (e.g. a local dev binary). - -### Environment - -| Variable | Effect | -|----------|--------| -| `CSDD_BIN` | Absolute path to the `csdd` binary. Highest-priority resolution. | -| `NO_COLOR` | Forced to `1` for every call so output is ANSI-free (you don't set this). | - ---- - -## Result & error semantics - -Every tool returns a text result. The mapping from the `csdd` exit code is: - -| Exit | `isError` | Result text | -|------|-----------|-------------| -| `0` | `false` | `stdout` (and any `stderr` as an unlabelled warning); `(ok, no output)` if silent. | -| `2` | `true` | Prefixed `csdd validation failed (exit 2):` — a contract/validation problem. | -| other | `true` | Prefixed `csdd failed (exit ):`; `stderr` is labelled `[stderr]`. | -| `127` | `true` | Binary not found — includes guidance to set `CSDD_BIN` / install / fix `PATH`. | - ---- - -## Tool reference - -**27 tools** covering the csdd **development flow**, grouped by resource. -Conventions: - -- Every tool accepts an optional **`root`** — the workspace root (the directory - containing `.claude/`). Omit it to walk up from the server's working directory. -- Destructive / gate-breaking tools take **`force`** (boolean). Without it, - deletes are refused and phase gates hold. -- `?` marks an optional parameter; everything else is required. - -> **Scope:** this server exposes only the iterative development-flow resources -> (steering · spec · skill · agent). **Workspace setup and config management are -> deliberately not tools** — `csdd init`, `csdd update`, `csdd mcp …`, and `csdd export …` are -> one-time operations a human runs from the CLI, not part of the loop an agent -> drives. (In fact, `csdd init` is what registers *this* server.) - -### Diagnostic - -| Tool | Parameters | What it does | -|------|------------|--------------| -| `csdd_version` | — | Print the underlying `csdd` binary version (diagnostic / connectivity check). | - -### 🧭 steering — project memory (`.claude/steering/*.md`) - -| Tool | Parameters | What it does | -|------|------------|--------------| -| `csdd_steering_init` | `root?` | Create `.claude/steering/` and the 6 standard files (product, tech, structure, security, testing, api-conventions). | -| `csdd_steering_create` | `name`, `inclusion`, `pattern?[]`, `description?`, `title?`, `force?`, `root?` | Create a custom steering file. `inclusion` ∈ `always · fileMatch · manual · auto`. `fileMatch` requires ≥1 `pattern`; `auto` requires a `description`. | -| `csdd_steering_list` | `root?`, `inclusion?` | List steering files with inclusion mode; optionally filter by `inclusion`. | -| `csdd_steering_show` | `name`, `root?` | Print a steering file (frontmatter + body). | -| `csdd_steering_delete` | `name`, `force?`, `root?` | Delete a steering file (`force` required). Foundational files (product, tech, structure) are protected. | -| `csdd_steering_validate` | `name?`, `root?` | Validate frontmatter/structure. Omit `name` to validate all. Exit 2 on issues. | - -`inclusion` controls *when* the steering loads: `always` (always-on), `fileMatch` -(when files match a `pattern`), `manual` (`#name`), `auto` (when its `description` -matches the context). - -### 📐 spec — per-feature contract (`specs//`) - -| Tool | Parameters | What it does | -|------|------------|--------------| -| `csdd_spec_init` | `feature`, `language?`, `root?` | Create `specs//spec.json` (phase = initial, no approvals). `language` defaults to `en`. | -| `csdd_spec_list` | `root?` | List specs with current phase, approved phases, and readiness. | -| `csdd_spec_show` | `feature`, `root?` | Show a spec's `spec.json` metadata and its artifacts. | -| `csdd_spec_status` | `feature`, `root?` | Combined `show` + `validate` for a spec. | -| `csdd_spec_generate` | `feature`, `artifact`, `force?`, `root?` | Generate an artifact. `artifact` ∈ `requirements · design · tasks · research · bugfix`. **Phase gates apply** (see below); `force` bypasses them. | -| `csdd_spec_approve` | `feature`, `phase`, `force?`, `root?` | Approve a phase. `phase` ∈ `requirements · design · tasks`. Validates first; `force` approves despite issues/missing prior approvals. | -| `csdd_spec_validate` | `feature`, `root?` | Validate EARS phrasing, traceability, task annotations, parallel safety. Exit 2 on issues. | -| `csdd_spec_delete` | `feature`, `force?`, `root?` | Delete `specs//` recursively (`force` required). | - -> **Phase gates (enforced, not advisory):** `design` needs `requirements` -> approved; `tasks` needs `design` approved. Generating out of order fails with -> **exit 2** unless `force` is passed. `ready_for_implementation` flips to `true` -> only when all three phases are approved. `research` and `bugfix` are ungated. - -### 🛠️ skill — workflow bundles (`.claude/skills//`) - -| Tool | Parameters | What it does | -|------|------------|--------------| -| `csdd_skill_create` | `name`, `description`, `title?`, `root?` | Create `.claude/skills//` with `SKILL.md` (+ `references/`, `assets/`, `scripts/`). `description` is the one-line activation trigger. The tool scaffolds the file — author the body by editing `SKILL.md` directly. | -| `csdd_skill_list` | `root?` | List skills with their descriptions. | -| `csdd_skill_show` | `name`, `root?` | List a skill's files and print `SKILL.md`. | -| `csdd_skill_add_reference` | `skill`, `file`, `root?` | Add a reference file under `references/`. Path traversal is rejected. | -| `csdd_skill_add_script` | `skill`, `file`, `root?` | Add a script file under `scripts/`. Path traversal is rejected. | -| `csdd_skill_add_asset` | `skill`, `file`, `root?` | Add an asset file under `assets/`. Path traversal is rejected. | -| `csdd_skill_validate` | `name`, `root?` | Validate structure + frontmatter; report line/token counts. Exit 2 on issues. | -| `csdd_skill_delete` | `name`, `force?`, `root?` | Delete `.claude/skills//` recursively (`force` required). | - -### 🤖 agent — custom sub-agents (`.claude/agents/.md`) - -| Tool | Parameters | What it does | -|------|------------|--------------| -| `csdd_agent_create` | `name`, `description`, `tools?[]`, `model?`, `title?`, `force?`, `root?` | Create a least-privilege sub-agent (default tools: `Read`, `Grep`). `description` tells the orchestrator when to pick it. Scaffolds the file; fill in the body by editing the generated `.md`. `model` ∈ `sonnet · opus · haiku`. | -| `csdd_agent_list` | `root?` | List agents with their tools and descriptions. | -| `csdd_agent_show` | `name`, `root?` | Print an agent file. | -| `csdd_agent_delete` | `name`, `force?`, `root?` | Delete `.claude/agents/.md` (`force` required). | - -> **Not here:** managing the `.mcp.json` servers themselves (`csdd mcp add/list/ -> remove/enable/disable/validate`) stays on the CLI — same for `csdd init`, `csdd update`, and -> `csdd export`. Keeping setup off the tool surface is intentional (see Scope). - ---- - -## A typical agent flow - -Setup is a one-time CLI step (`npx @protonspy/csdd init --with-baseline`, which -also registers this server). From there the agent drives the feature with tools: - -```jsonc -csdd_spec_init { "feature": "photo-albums" } - -csdd_spec_generate { "feature": "photo-albums", "artifact": "requirements" } -csdd_spec_validate { "feature": "photo-albums" } // exit 2 → fix what it flags -csdd_spec_approve { "feature": "photo-albums", "phase": "requirements" } - -csdd_spec_generate { "feature": "photo-albums", "artifact": "design" } // gated on the approval above -csdd_spec_approve { "feature": "photo-albums", "phase": "design" } - -csdd_spec_generate { "feature": "photo-albums", "artifact": "tasks" } -csdd_spec_approve { "feature": "photo-albums", "phase": "tasks" } -// → spec.json: ready_for_implementation = true -``` - -`csdd_spec_status { "feature": "photo-albums" }` between steps shows phase, -approvals, and validation issues in one call. - ---- - -## Development - -```bash -npm install -npm run build # tsc → dist/ -npm test # build + Node's built-in test runner (node:test) -npm run test:run # tests only, against the current dist/ (no rebuild) -npm run dev # tsc --watch -``` - -Tests are TypeScript run through `node:test` with native **type stripping**, so -they need **Node ≥ 22.18** (dev-only — the published package still targets Node -≥ 18). They exercise the argv builders, result formatting, binary resolution, -`runCsdd` against a stub binary, and every tool's argv mapping. See `test/`. - ---- - -## License - -MIT +# @protonspy/csdd-mcp + +**An [MCP](https://modelcontextprotocol.io) server that exposes the [`csdd`](https://github.com/protonspy/csdd) CLI as tools — one tool per subcommand.** + +`csdd` governs the Claude Code Spec-Driven Development workflow (steering, specs, +skills, sub-agents, MCP servers) and validates the contract mechanically. This +server lets an MCP-capable agent drive `csdd` **directly as tools**, instead of +shelling out to a terminal — same operations, same phase gates, same exit codes. + +``` +agent ──(MCP/stdio)──▶ csdd-mcp ──(execFile)──▶ csdd binary ──▶ .claude/ · specs/ +``` + +Each tool builds a `csdd` argv, runs the binary headlessly (`NO_COLOR=1`, no TTY +so confirmations auto-decline), and returns its `stdout`/`stderr`. A non-zero +exit becomes an MCP error result; **exit `2` (validation failure) is surfaced +distinctly** so the agent can tell "your spec is invalid" from "the command +broke". + +--- + +## Requirements + +- **Node.js ≥ 18** (the published package ships compiled JS). +- **The `csdd` binary**, reachable by the server — see [Locating the csdd binary](#locating-the-csdd-binary). + +--- + +## Install & configure + +The server runs over **stdio**; point your MCP client at it. + +### Claude Code + +```bash +# project scope (writes .mcp.json) — or use --scope user for all projects +claude mcp add csdd -- npx -y @protonspy/csdd-mcp +``` + +Or add it to `.mcp.json` by hand: + +```json +{ + "mcpServers": { + "csdd": { + "command": "npx", + "args": ["-y", "@protonspy/csdd-mcp"], + "env": { "CSDD_BIN": "/usr/local/bin/csdd" } + } + } +} +``` + +> `env.CSDD_BIN` is optional — drop it if `csdd` is on your `PATH`. See below. + +### Any MCP client + +Launch `npx -y @protonspy/csdd-mcp` (or `csdd-mcp` if installed globally) as a +stdio server. The process stays alive serving stdio until the client closes the +pipe. + +--- + +## Locating the csdd binary + +The server resolves `csdd` once, on first use, in this order (first hit wins): + +| # | Source | When it applies | +|---|--------|-----------------| +| 1 | **`$CSDD_BIN`** | Explicit absolute path. Always wins — use this if in doubt. | +| 2 | **Platform package** `@protonspy/csdd--` | Declared as an `optionalDependency` of this package, so `npx`/`npm i` fetches the prebuilt binary for your OS/arch automatically — the zero-config path. | +| 3 | **Sibling repo binary** (`../csdd`, `../../csdd`) | When running from a checkout of the csdd repo. | +| 4 | **`csdd` on `$PATH`** | Last resort, resolved by the OS at spawn time. | + +If none resolve, calls fail with **exit `127`** and a message telling you to set +`CSDD_BIN`, install `@protonspy/csdd`, or put `csdd` on your `PATH`. + +> **Zero-config:** running via `npx -y @protonspy/csdd-mcp` pulls the matching +> binary through #2 automatically — nothing to install. Set `CSDD_BIN` only to +> pin a specific build (e.g. a local dev binary). + +### Environment + +| Variable | Effect | +|----------|--------| +| `CSDD_BIN` | Absolute path to the `csdd` binary. Highest-priority resolution. | +| `NO_COLOR` | Forced to `1` for every call so output is ANSI-free (you don't set this). | + +--- + +## Result & error semantics + +Every tool returns a text result. The mapping from the `csdd` exit code is: + +| Exit | `isError` | Result text | +|------|-----------|-------------| +| `0` | `false` | `stdout` (and any `stderr` as an unlabelled warning); `(ok, no output)` if silent. | +| `2` | `true` | Prefixed `csdd validation failed (exit 2):` — a contract/validation problem. | +| other | `true` | Prefixed `csdd failed (exit ):`; `stderr` is labelled `[stderr]`. | +| `127` | `true` | Binary not found — includes guidance to set `CSDD_BIN` / install / fix `PATH`. | + +--- + +## Tool reference + +**27 tools** covering the csdd **development flow**, grouped by resource. +Conventions: + +- Every tool accepts an optional **`root`** — the workspace root (the directory + containing `.claude/`). Omit it to walk up from the server's working directory. +- Destructive / gate-breaking tools take **`force`** (boolean). Without it, + deletes are refused and phase gates hold. +- `?` marks an optional parameter; everything else is required. + +> **Scope:** this server exposes only the iterative development-flow resources +> (steering · spec · skill · agent). **Workspace setup and config management are +> deliberately not tools** — `csdd init`, `csdd update`, `csdd mcp …`, and `csdd export …` are +> one-time operations a human runs from the CLI, not part of the loop an agent +> drives. (In fact, `csdd init` is what registers *this* server.) + +### Diagnostic + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_version` | — | Print the underlying `csdd` binary version (diagnostic / connectivity check). | + +### 🧭 steering — project memory (`.claude/steering/*.md`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_steering_init` | `root?` | Create `.claude/steering/` and the 6 standard files (product, tech, structure, security, testing, api-conventions). | +| `csdd_steering_create` | `name`, `inclusion`, `pattern?[]`, `description?`, `title?`, `force?`, `root?` | Create a custom steering file. `inclusion` ∈ `always · fileMatch · manual · auto`. `fileMatch` requires ≥1 `pattern`; `auto` requires a `description`. | +| `csdd_steering_list` | `root?`, `inclusion?` | List steering files with inclusion mode; optionally filter by `inclusion`. | +| `csdd_steering_show` | `name`, `root?` | Print a steering file (frontmatter + body). | +| `csdd_steering_delete` | `name`, `force?`, `root?` | Delete a steering file (`force` required). Foundational files (product, tech, structure) are protected. | +| `csdd_steering_validate` | `name?`, `root?` | Validate frontmatter/structure. Omit `name` to validate all. Exit 2 on issues. | + +`inclusion` controls *when* the steering loads: `always` (always-on), `fileMatch` +(when files match a `pattern`), `manual` (`#name`), `auto` (when its `description` +matches the context). + +### 📐 spec — per-feature contract (`specs//`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_spec_init` | `feature`, `language?`, `root?` | Create `specs//spec.json` (phase = initial, no approvals). `language` defaults to `en`. | +| `csdd_spec_list` | `root?` | List specs with current phase, approved phases, and readiness. | +| `csdd_spec_show` | `feature`, `root?` | Show a spec's `spec.json` metadata and its artifacts. | +| `csdd_spec_status` | `feature`, `root?` | Combined `show` + `validate` for a spec. | +| `csdd_spec_generate` | `feature`, `artifact`, `force?`, `root?` | Generate an artifact. `artifact` ∈ `requirements · design · tasks · research · bugfix`. **Phase gates apply** (see below); `force` bypasses them. | +| `csdd_spec_approve` | `feature`, `phase`, `force?`, `root?` | Approve a phase. `phase` ∈ `requirements · design · tasks`. Validates first; `force` approves despite issues/missing prior approvals. | +| `csdd_spec_validate` | `feature`, `root?` | Validate EARS phrasing, traceability, task annotations, parallel safety. Exit 2 on issues. | +| `csdd_spec_delete` | `feature`, `force?`, `root?` | Delete `specs//` recursively (`force` required). | + +> **Phase gates (enforced, not advisory):** `design` needs `requirements` +> approved; `tasks` needs `design` approved. Generating out of order fails with +> **exit 2** unless `force` is passed. `ready_for_implementation` flips to `true` +> only when all three phases are approved. `research` and `bugfix` are ungated. + +### 🛠️ skill — workflow bundles (`.claude/skills//`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_skill_create` | `name`, `description`, `title?`, `root?` | Create `.claude/skills//` with `SKILL.md` (+ `references/`, `assets/`, `scripts/`). `description` is the one-line activation trigger. The tool scaffolds the file — author the body by editing `SKILL.md` directly. | +| `csdd_skill_list` | `root?` | List skills with their descriptions. | +| `csdd_skill_show` | `name`, `root?` | List a skill's files and print `SKILL.md`. | +| `csdd_skill_add_reference` | `skill`, `file`, `root?` | Add a reference file under `references/`. Path traversal is rejected. | +| `csdd_skill_add_script` | `skill`, `file`, `root?` | Add a script file under `scripts/`. Path traversal is rejected. | +| `csdd_skill_add_asset` | `skill`, `file`, `root?` | Add an asset file under `assets/`. Path traversal is rejected. | +| `csdd_skill_validate` | `name`, `root?` | Validate structure + frontmatter; report line/token counts. Exit 2 on issues. | +| `csdd_skill_delete` | `name`, `force?`, `root?` | Delete `.claude/skills//` recursively (`force` required). | + +### 🤖 agent — custom sub-agents (`.claude/agents/.md`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_agent_create` | `name`, `description`, `tools?[]`, `model?`, `title?`, `force?`, `root?` | Create a least-privilege sub-agent (default tools: `Read`, `Grep`). `description` tells the orchestrator when to pick it. Scaffolds the file; fill in the body by editing the generated `.md`. `model` ∈ `sonnet · opus · haiku`. | +| `csdd_agent_list` | `root?` | List agents with their tools and descriptions. | +| `csdd_agent_show` | `name`, `root?` | Print an agent file. | +| `csdd_agent_delete` | `name`, `force?`, `root?` | Delete `.claude/agents/.md` (`force` required). | + +> **Not here:** managing the `.mcp.json` servers themselves (`csdd mcp add/list/ +> remove/enable/disable/validate`) stays on the CLI — same for `csdd init`, `csdd update`, and +> `csdd export`. Keeping setup off the tool surface is intentional (see Scope). + +--- + +## A typical agent flow + +Setup is a one-time CLI step (`npx @protonspy/csdd init --with-baseline`, which +also registers this server). From there the agent drives the feature with tools: + +```jsonc +csdd_spec_init { "feature": "photo-albums" } + +csdd_spec_generate { "feature": "photo-albums", "artifact": "requirements" } +csdd_spec_validate { "feature": "photo-albums" } // exit 2 → fix what it flags +csdd_spec_approve { "feature": "photo-albums", "phase": "requirements" } + +csdd_spec_generate { "feature": "photo-albums", "artifact": "design" } // gated on the approval above +csdd_spec_approve { "feature": "photo-albums", "phase": "design" } + +csdd_spec_generate { "feature": "photo-albums", "artifact": "tasks" } +csdd_spec_approve { "feature": "photo-albums", "phase": "tasks" } +// → spec.json: ready_for_implementation = true +``` + +`csdd_spec_status { "feature": "photo-albums" }` between steps shows phase, +approvals, and validation issues in one call. + +--- + +## Development + +```bash +npm install +npm run build # tsc → dist/ +npm test # build + Node's built-in test runner (node:test) +npm run test:run # tests only, against the current dist/ (no rebuild) +npm run dev # tsc --watch +``` + +Tests are TypeScript run through `node:test` with native **type stripping**, so +they need **Node ≥ 22.18** (dev-only — the published package still targets Node +≥ 18). They exercise the argv builders, result formatting, binary resolution, +`runCsdd` against a stub binary, and every tool's argv mapping. See `test/`. + +--- + +## License + +MIT diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 1a4a667..351afca 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,1270 +1,1270 @@ -{ - "name": "@protonspy/csdd-mcp", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@protonspy/csdd-mcp", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.0", - "zod": "^3.23.8" - }, - "bin": { - "csdd-mcp": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^20.14.0", - "typescript": "^5.5.0" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@protonspy/csdd-darwin-arm64": "^0.1.0", - "@protonspy/csdd-darwin-x64": "^0.1.0", - "@protonspy/csdd-linux-arm64": "^0.1.0", - "@protonspy/csdd-linux-x64": "^0.1.0", - "@protonspy/csdd-win32-x64": "^0.1.0" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@protonspy/csdd-darwin-arm64": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@protonspy/csdd-darwin-arm64/-/csdd-darwin-arm64-0.1.1.tgz", - "integrity": "sha512-l6EPBMnP578H+ukzoHI4w12e7c3PnQsHoTm7HuFtk+pKbTWvPgaSc1OVubOFCZFsELypmUyS6uHKdyBbee0nWg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@protonspy/csdd-darwin-x64": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@protonspy/csdd-darwin-x64/-/csdd-darwin-x64-0.1.1.tgz", - "integrity": "sha512-oaMz9pUYno4B9OzDgbSwMBaH9XnJitXQhv29uQith3vwEnwTkNEFKgWuucRR3FXDW2GQFNVY8lSfmPYgrcu3Ig==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@protonspy/csdd-linux-arm64": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@protonspy/csdd-linux-arm64/-/csdd-linux-arm64-0.1.1.tgz", - "integrity": "sha512-eS9hnTDoUSvX++FBQYxwWGsp2vBfwQv5me1hsNAj8pOQha0ylwG9AFhqPgsOAUcvMkL8fmKSA1DWQwpN3YlVpw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@protonspy/csdd-linux-x64": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@protonspy/csdd-linux-x64/-/csdd-linux-x64-0.1.1.tgz", - "integrity": "sha512-TQk4JY/s9vRqoM4l3JJhyorQCDqxfcdycuaruDbX7E1UR3Em6eqKHHiBRX2PvXXzSY4wyGuhDR1B7GwABfReZQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@protonspy/csdd-win32-x64": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@protonspy/csdd-win32-x64/-/csdd-win32-x64-0.1.1.tgz", - "integrity": "sha512-lOWyTZ3IPBV/NRgvKc5c1BZ2lr3DO27TbSkop8Jn7wQrTx8TNjGTudB7Fgrn+Tl9wYz6OhtDaypCXbqcpYTMJQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - } - } -} +{ + "name": "@protonspy/csdd-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@protonspy/csdd-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + }, + "bin": { + "csdd-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@protonspy/csdd-darwin-arm64": "^0.1.0", + "@protonspy/csdd-darwin-x64": "^0.1.0", + "@protonspy/csdd-linux-arm64": "^0.1.0", + "@protonspy/csdd-linux-x64": "^0.1.0", + "@protonspy/csdd-win32-x64": "^0.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@protonspy/csdd-darwin-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-darwin-arm64/-/csdd-darwin-arm64-0.1.1.tgz", + "integrity": "sha512-l6EPBMnP578H+ukzoHI4w12e7c3PnQsHoTm7HuFtk+pKbTWvPgaSc1OVubOFCZFsELypmUyS6uHKdyBbee0nWg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@protonspy/csdd-darwin-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-darwin-x64/-/csdd-darwin-x64-0.1.1.tgz", + "integrity": "sha512-oaMz9pUYno4B9OzDgbSwMBaH9XnJitXQhv29uQith3vwEnwTkNEFKgWuucRR3FXDW2GQFNVY8lSfmPYgrcu3Ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@protonspy/csdd-linux-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-linux-arm64/-/csdd-linux-arm64-0.1.1.tgz", + "integrity": "sha512-eS9hnTDoUSvX++FBQYxwWGsp2vBfwQv5me1hsNAj8pOQha0ylwG9AFhqPgsOAUcvMkL8fmKSA1DWQwpN3YlVpw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@protonspy/csdd-linux-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-linux-x64/-/csdd-linux-x64-0.1.1.tgz", + "integrity": "sha512-TQk4JY/s9vRqoM4l3JJhyorQCDqxfcdycuaruDbX7E1UR3Em6eqKHHiBRX2PvXXzSY4wyGuhDR1B7GwABfReZQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@protonspy/csdd-win32-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-win32-x64/-/csdd-win32-x64-0.1.1.tgz", + "integrity": "sha512-lOWyTZ3IPBV/NRgvKc5c1BZ2lr3DO27TbSkop8Jn7wQrTx8TNjGTudB7Fgrn+Tl9wYz6OhtDaypCXbqcpYTMJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json index e0e6f10..175f7ac 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,57 +1,57 @@ -{ - "name": "@protonspy/csdd-mcp", - "version": "0.1.0", - "description": "MCP server exposing the csdd CLI over stdio (one tool per subcommand).", - "homepage": "https://github.com/protonspy/csdd/tree/main/mcp-server#readme", - "bugs": { - "url": "https://github.com/protonspy/csdd/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/protonspy/csdd.git", - "directory": "mcp-server" - }, - "type": "module", - "bin": { - "csdd-mcp": "dist/index.js" - }, - "main": "dist/index.js", - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "dev": "tsc -w -p tsconfig.json", - "start": "node dist/index.js", - "test": "npm run build && node --test \"test/**/*.test.ts\"", - "test:run": "node --test \"test/**/*.test.ts\"", - "prepublishOnly": "npm run build" - }, - "engines": { - "node": ">=18" - }, - "keywords": [ - "mcp", - "model-context-protocol", - "csdd", - "claude-code", - "sdd" - ], - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.0", - "zod": "^3.23.8" - }, - "optionalDependencies": { - "@protonspy/csdd-linux-x64": "^0.1.0", - "@protonspy/csdd-linux-arm64": "^0.1.0", - "@protonspy/csdd-darwin-x64": "^0.1.0", - "@protonspy/csdd-darwin-arm64": "^0.1.0", - "@protonspy/csdd-win32-x64": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^20.14.0", - "typescript": "^5.5.0" - } -} +{ + "name": "@protonspy/csdd-mcp", + "version": "0.1.0", + "description": "MCP server exposing the csdd CLI over stdio (one tool per subcommand).", + "homepage": "https://github.com/protonspy/csdd/tree/main/mcp-server#readme", + "bugs": { + "url": "https://github.com/protonspy/csdd/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/protonspy/csdd.git", + "directory": "mcp-server" + }, + "type": "module", + "bin": { + "csdd-mcp": "dist/index.js" + }, + "main": "dist/index.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -w -p tsconfig.json", + "start": "node dist/index.js", + "test": "npm run build && node --test \"test/**/*.test.ts\"", + "test:run": "node --test \"test/**/*.test.ts\"", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "csdd", + "claude-code", + "sdd" + ], + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + }, + "optionalDependencies": { + "@protonspy/csdd-linux-x64": "^0.1.0", + "@protonspy/csdd-linux-arm64": "^0.1.0", + "@protonspy/csdd-darwin-x64": "^0.1.0", + "@protonspy/csdd-darwin-arm64": "^0.1.0", + "@protonspy/csdd-win32-x64": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } +} diff --git a/mcp-server/src/csdd.ts b/mcp-server/src/csdd.ts index d559315..affe551 100644 --- a/mcp-server/src/csdd.ts +++ b/mcp-server/src/csdd.ts @@ -1,116 +1,117 @@ -// Resolve the csdd binary and run it headlessly, capturing structured output. -// -// Resolution order (first hit wins): -// 1. $CSDD_BIN — explicit override -// 2. platform optionalDependency — @protonspy/csdd-- -// (the same packages the npm launcher uses) -// 3. sibling repo binary — ../csdd (when running from the repo) -// 4. "csdd" on $PATH — last-resort lookup at spawn time -import { execFile } from "node:child_process"; -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); - -// platform/arch -> npm package name (must match the npm launcher's TARGETS). -const PKG: Record = { - "linux-x64": "@protonspy/csdd-linux-x64", - "linux-arm64": "@protonspy/csdd-linux-arm64", - "darwin-x64": "@protonspy/csdd-darwin-x64", - "darwin-arm64": "@protonspy/csdd-darwin-arm64", - "win32-x64": "@protonspy/csdd-win32-x64", -}; - -const binName = process.platform === "win32" ? "csdd.exe" : "csdd"; - -function fromPlatformPackage(): string | null { - const pkgName = PKG[`${process.platform}-${process.arch}`]; - if (!pkgName) return null; - try { - const pkgJson = require.resolve(`${pkgName}/package.json`); - const candidate = join(pkgJson, "..", "bin", binName); - return existsSync(candidate) ? candidate : null; - } catch { - return null; - } -} - -function fromSiblingRepo(): string | null { - // dist/csdd.js -> packageRoot = mcp-server -> repo root holds ./csdd - const here = dirname(fileURLToPath(import.meta.url)); - for (const up of ["..", "../.."]) { - const candidate = join(here, up, binName); - if (existsSync(candidate)) return candidate; - } - return null; -} - -let cached: string | null = null; - -/** Locate the csdd binary, caching the result. Falls back to a bare "csdd". */ -export function resolveCsddBin(): string { - if (cached) return cached; - cached = - process.env.CSDD_BIN || - fromPlatformPackage() || - fromSiblingRepo() || - binName; // let the OS resolve it on $PATH at spawn time - return cached; -} - -export interface CsddResult { - ok: boolean; - exitCode: number; - stdout: string; - stderr: string; -} - -/** - * Run `csdd` with the given argv. Never rejects on a non-zero exit — the exit - * code is returned so each tool can map it to an MCP error result. - * `cwd` controls workspace discovery when no --root flag is passed. - */ -export function runCsdd(argv: string[], cwd?: string): Promise { - const bin = resolveCsddBin(); - return new Promise((resolve) => { - execFile( - bin, - argv, - { - cwd: cwd || process.cwd(), - // NO_COLOR keeps output free of ANSI escapes; csdd is non-interactive - // (confirm() returns false) when stdin is not a TTY, which it isn't here. - env: { ...process.env, NO_COLOR: "1" }, - maxBuffer: 16 * 1024 * 1024, - windowsHide: true, - }, - (err, stdout, stderr) => { - const code = - err && typeof (err as NodeJS.ErrnoException).code === "string" - ? // spawn failure (ENOENT etc.) — surface as exit 127 - 127 - : ((err as { code?: number } | null)?.code ?? 0); - if (err && code === 127) { - resolve({ - ok: false, - exitCode: 127, - stdout: stdout?.toString() ?? "", - stderr: - (stderr?.toString() ?? "") + - `\ncsdd binary not found or not executable: ${bin}\n` + - `Set CSDD_BIN, install @protonspy/csdd, or put csdd on PATH.`, - }); - return; - } - resolve({ - ok: code === 0, - exitCode: typeof code === "number" ? code : 1, - stdout: stdout?.toString() ?? "", - stderr: stderr?.toString() ?? "", - }); - }, - ); - }); -} +// Resolve the csdd binary and run it headlessly, capturing structured output. +// +// Resolution order (first hit wins): +// 1. $CSDD_BIN — explicit override +// 2. platform optionalDependency — @protonspy/csdd-- +// (the same packages the npm launcher uses) +// 3. sibling repo binary — ../csdd (when running from the repo) +// 4. "csdd" on $PATH — last-resort lookup at spawn time +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); + +// platform/arch -> npm package name (must match the npm launcher's TARGETS). +const PKG: Record = { + "linux-x64": "@protonspy/csdd-linux-x64", + "linux-arm64": "@protonspy/csdd-linux-arm64", + "darwin-x64": "@protonspy/csdd-darwin-x64", + "darwin-arm64": "@protonspy/csdd-darwin-arm64", + "win32-x64": "@protonspy/csdd-win32-x64", + "win32-arm64": "@protonspy/csdd-win32-arm64", +}; + +const binName = process.platform === "win32" ? "csdd.exe" : "csdd"; + +function fromPlatformPackage(): string | null { + const pkgName = PKG[`${process.platform}-${process.arch}`]; + if (!pkgName) return null; + try { + const pkgJson = require.resolve(`${pkgName}/package.json`); + const candidate = join(pkgJson, "..", "bin", binName); + return existsSync(candidate) ? candidate : null; + } catch { + return null; + } +} + +function fromSiblingRepo(): string | null { + // dist/csdd.js -> packageRoot = mcp-server -> repo root holds ./csdd + const here = dirname(fileURLToPath(import.meta.url)); + for (const up of ["..", "../.."]) { + const candidate = join(here, up, binName); + if (existsSync(candidate)) return candidate; + } + return null; +} + +let cached: string | null = null; + +/** Locate the csdd binary, caching the result. Falls back to a bare "csdd". */ +export function resolveCsddBin(): string { + if (cached) return cached; + cached = + process.env.CSDD_BIN || + fromPlatformPackage() || + fromSiblingRepo() || + binName; // let the OS resolve it on $PATH at spawn time + return cached; +} + +export interface CsddResult { + ok: boolean; + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Run `csdd` with the given argv. Never rejects on a non-zero exit — the exit + * code is returned so each tool can map it to an MCP error result. + * `cwd` controls workspace discovery when no --root flag is passed. + */ +export function runCsdd(argv: string[], cwd?: string): Promise { + const bin = resolveCsddBin(); + return new Promise((resolve) => { + execFile( + bin, + argv, + { + cwd: cwd || process.cwd(), + // NO_COLOR keeps output free of ANSI escapes; csdd is non-interactive + // (confirm() returns false) when stdin is not a TTY, which it isn't here. + env: { ...process.env, NO_COLOR: "1" }, + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, + }, + (err, stdout, stderr) => { + const code = + err && typeof (err as NodeJS.ErrnoException).code === "string" + ? // spawn failure (ENOENT etc.) — surface as exit 127 + 127 + : ((err as { code?: number } | null)?.code ?? 0); + if (err && code === 127) { + resolve({ + ok: false, + exitCode: 127, + stdout: stdout?.toString() ?? "", + stderr: + (stderr?.toString() ?? "") + + `\ncsdd binary not found or not executable: ${bin}\n` + + `Set CSDD_BIN, install @protonspy/csdd, or put csdd on PATH.`, + }); + return; + } + resolve({ + ok: code === 0, + exitCode: typeof code === "number" ? code : 1, + stdout: stdout?.toString() ?? "", + stderr: stderr?.toString() ?? "", + }); + }, + ); + }); +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 1b23ae9..79110d0 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -1,38 +1,48 @@ -#!/usr/bin/env node -// csdd-mcp — an MCP server (stdio) that exposes the csdd CLI as one tool per -// subcommand. Each tool shells out to the csdd binary headlessly and returns -// its stdout/stderr; non-zero exits are surfaced as MCP errors (exit 2 = -// validation failure). -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -import { makeHandler } from "./tooldef.js"; -import { allTools } from "./registry.js"; - -const version = "0.1.0"; - -async function main() { - const server = new McpServer({ name: "csdd-mcp", version }); - - for (const def of allTools) { - server.registerTool( - def.name, - { - title: def.title, - description: def.description, - inputSchema: def.inputSchema, - }, - makeHandler(def), - ); - } - - const transport = new StdioServerTransport(); - await server.connect(transport); - // Never resolves; the process stays alive serving stdio until the client - // closes the pipe. -} - -main().catch((err) => { - process.stderr.write(`csdd-mcp fatal: ${err?.stack || err}\n`); - process.exit(1); -}); +#!/usr/bin/env node +// csdd-mcp — an MCP server (stdio) that exposes the csdd CLI as one tool per +// subcommand. Each tool shells out to the csdd binary headlessly and returns +// its stdout/stderr; non-zero exits are surfaced as MCP errors (exit 2 = +// validation failure). +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { makeHandler } from "./tooldef.js"; +import { allTools } from "./registry.js"; + +// Read our own version at runtime so it never drifts from the published package. +// The compiled entry lives at /dist/index.js, so package.json is one level +// up — true both in the repo (mcp-server/) and in the published @protonspy/csdd-mcp +// package. Read via fs (not a JSON import) so tsc doesn't pull package.json into +// the rootDir:src compilation. +const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); +const { version } = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }; + +async function main() { + const server = new McpServer({ name: "csdd-mcp", version }); + + for (const def of allTools) { + server.registerTool( + def.name, + { + title: def.title, + description: def.description, + inputSchema: def.inputSchema, + }, + makeHandler(def), + ); + } + + const transport = new StdioServerTransport(); + await server.connect(transport); + // Never resolves; the process stays alive serving stdio until the client + // closes the pipe. +} + +main().catch((err) => { + process.stderr.write(`csdd-mcp fatal: ${err?.stack || err}\n`); + process.exit(1); +}); diff --git a/mcp-server/src/registry.ts b/mcp-server/src/registry.ts index 580c0bc..d273ca5 100644 --- a/mcp-server/src/registry.ts +++ b/mcp-server/src/registry.ts @@ -1,29 +1,29 @@ -// The MCP tools this server exposes — the csdd *development-flow* resources -// (steering, spec, skill, agent), plus a diagnostic version tool. Workspace -// setup, maintenance, and config management (init, update, mcp, export) are intentionally NOT exposed: -// those are one-time CLI operations a human runs, not part of the iterative -// loop the agent drives. Kept separate from index.ts so it can be imported -// (e.g. by tests) without booting the stdio transport. -import { type ToolDef } from "./tooldef.js"; -import { steeringTools } from "./tools/steering.js"; -import { specTools } from "./tools/spec.js"; -import { skillTools } from "./tools/skill.js"; -import { agentTools } from "./tools/agent.js"; - -export const miscTools: ToolDef[] = [ - { - name: "csdd_version", - title: "csdd version", - description: "Print the version of the underlying csdd binary (diagnostic).", - inputSchema: {}, - toArgs: () => ["version"], - }, -]; - -export const allTools: ToolDef[] = [ - ...miscTools, - ...steeringTools, - ...specTools, - ...skillTools, - ...agentTools, -]; +// The MCP tools this server exposes — the csdd *development-flow* resources +// (steering, spec, skill, agent), plus a diagnostic version tool. Workspace +// setup, maintenance, and config management (init, update, mcp, export) are intentionally NOT exposed: +// those are one-time CLI operations a human runs, not part of the iterative +// loop the agent drives. Kept separate from index.ts so it can be imported +// (e.g. by tests) without booting the stdio transport. +import { type ToolDef } from "./tooldef.js"; +import { steeringTools } from "./tools/steering.js"; +import { specTools } from "./tools/spec.js"; +import { skillTools } from "./tools/skill.js"; +import { agentTools } from "./tools/agent.js"; + +export const miscTools: ToolDef[] = [ + { + name: "csdd_version", + title: "csdd version", + description: "Print the version of the underlying csdd binary (diagnostic).", + inputSchema: {}, + toArgs: () => ["version"], + }, +]; + +export const allTools: ToolDef[] = [ + ...miscTools, + ...steeringTools, + ...specTools, + ...skillTools, + ...agentTools, +]; diff --git a/mcp-server/src/tooldef.ts b/mcp-server/src/tooldef.ts index 42af197..7b71f63 100644 --- a/mcp-server/src/tooldef.ts +++ b/mcp-server/src/tooldef.ts @@ -1,85 +1,85 @@ -// Shared types + helpers for declaring csdd-backed MCP tools. -import { z, type ZodRawShape } from "zod"; -import { runCsdd, type CsddResult } from "./csdd.js"; - -export interface ToolDef { - /** MCP tool name, e.g. "csdd_spec_generate". */ - name: string; - /** Human title shown in clients. */ - title: string; - /** When/why an agent should call this tool. */ - description: string; - /** Zod raw shape (object of field schemas) describing the inputs. */ - inputSchema: ZodRawShape; - /** Map validated params to the csdd argv (excluding the binary itself). */ - toArgs: (params: Record) => string[]; -} - -// --- argv builders --------------------------------------------------------- - -/** `--flag value` when value is a non-empty string/number, else nothing. */ -export function flag(name: string, value: unknown): string[] { - if (value === undefined || value === null || value === "") return []; - return [name, String(value)]; -} - -/** `--flag` when truthy, else nothing. */ -export function bool(name: string, value: unknown): string[] { - return value ? [name] : []; -} - -/** Repeat `--flag value` for each item. */ -export function multi(name: string, values: unknown): string[] { - if (!Array.isArray(values)) return []; - return values.flatMap((v) => [name, String(v)]); -} - -/** Standard `--root` passthrough (most commands accept it). */ -export function rootArg(params: Record): string[] { - return flag("--root", params.root); -} - -// Reusable field schemas ----------------------------------------------------- -export const rootField = z - .string() - .optional() - .describe( - "Workspace root (the directory containing .claude/). Defaults to walking up from the current directory.", - ); - -export const forceField = z - .boolean() - .optional() - .describe("Bypass safety checks / overwrite or delete without confirmation."); - -// --- result formatting ----------------------------------------------------- - -/** Turn a csdd run into an MCP tool result, flagging non-zero exits. */ -export function toMcpResult(r: CsddResult) { - const parts: string[] = []; - if (r.stdout.trim()) parts.push(r.stdout.trimEnd()); - if (r.stderr.trim()) parts.push((r.ok ? "" : "[stderr] ") + r.stderr.trimEnd()); - if (parts.length === 0) { - parts.push(r.ok ? "(ok, no output)" : `csdd exited with code ${r.exitCode}`); - } - // Exit 2 = validation failure; surface it distinctly in the text. - const header = - r.exitCode === 2 - ? "csdd validation failed (exit 2):\n" - : r.ok - ? "" - : `csdd failed (exit ${r.exitCode}):\n`; - return { - content: [{ type: "text" as const, text: header + parts.join("\n\n") }], - isError: !r.ok, - }; -} - -/** Build the handler that runs a ToolDef's argv and formats the result. */ -export function makeHandler(def: ToolDef) { - return async (params: Record) => { - const argv = def.toArgs(params ?? {}); - const result = await runCsdd(argv, params?.root); - return toMcpResult(result); - }; -} +// Shared types + helpers for declaring csdd-backed MCP tools. +import { z, type ZodRawShape } from "zod"; +import { runCsdd, type CsddResult } from "./csdd.js"; + +export interface ToolDef { + /** MCP tool name, e.g. "csdd_spec_generate". */ + name: string; + /** Human title shown in clients. */ + title: string; + /** When/why an agent should call this tool. */ + description: string; + /** Zod raw shape (object of field schemas) describing the inputs. */ + inputSchema: ZodRawShape; + /** Map validated params to the csdd argv (excluding the binary itself). */ + toArgs: (params: Record) => string[]; +} + +// --- argv builders --------------------------------------------------------- + +/** `--flag value` when value is a non-empty string/number, else nothing. */ +export function flag(name: string, value: unknown): string[] { + if (value === undefined || value === null || value === "") return []; + return [name, String(value)]; +} + +/** `--flag` when truthy, else nothing. */ +export function bool(name: string, value: unknown): string[] { + return value ? [name] : []; +} + +/** Repeat `--flag value` for each item. */ +export function multi(name: string, values: unknown): string[] { + if (!Array.isArray(values)) return []; + return values.flatMap((v) => [name, String(v)]); +} + +/** Standard `--root` passthrough (most commands accept it). */ +export function rootArg(params: Record): string[] { + return flag("--root", params.root); +} + +// Reusable field schemas ----------------------------------------------------- +export const rootField = z + .string() + .optional() + .describe( + "Workspace root (the directory containing .claude/). Defaults to walking up from the current directory.", + ); + +export const forceField = z + .boolean() + .optional() + .describe("Bypass safety checks / overwrite or delete without confirmation."); + +// --- result formatting ----------------------------------------------------- + +/** Turn a csdd run into an MCP tool result, flagging non-zero exits. */ +export function toMcpResult(r: CsddResult) { + const parts: string[] = []; + if (r.stdout.trim()) parts.push(r.stdout.trimEnd()); + if (r.stderr.trim()) parts.push((r.ok ? "" : "[stderr] ") + r.stderr.trimEnd()); + if (parts.length === 0) { + parts.push(r.ok ? "(ok, no output)" : `csdd exited with code ${r.exitCode}`); + } + // Exit 2 = validation failure; surface it distinctly in the text. + const header = + r.exitCode === 2 + ? "csdd validation failed (exit 2):\n" + : r.ok + ? "" + : `csdd failed (exit ${r.exitCode}):\n`; + return { + content: [{ type: "text" as const, text: header + parts.join("\n\n") }], + isError: !r.ok, + }; +} + +/** Build the handler that runs a ToolDef's argv and formats the result. */ +export function makeHandler(def: ToolDef) { + return async (params: Record) => { + const argv = def.toArgs(params ?? {}); + const result = await runCsdd(argv, params?.root); + return toMcpResult(result); + }; +} diff --git a/mcp-server/src/tools/agent.ts b/mcp-server/src/tools/agent.ts index 58aafa8..eb96d37 100644 --- a/mcp-server/src/tools/agent.ts +++ b/mcp-server/src/tools/agent.ts @@ -1,67 +1,67 @@ -import { z } from "zod"; -import { - bool, - flag, - forceField, - multi, - rootArg, - rootField, - type ToolDef, -} from "../tooldef.js"; - -const agentName = z.string().describe("Agent name (.claude/agents/.md)."); - -export const agentTools: ToolDef[] = [ - { - name: "csdd_agent_create", - title: "Agent create", - description: - "Create a custom sub-agent with least-privilege tools (default: Read, Grep). Description tells the orchestrator when to pick it.", - inputSchema: { - name: agentName, - description: z.string().describe("When the orchestrator should select this agent."), - tools: z - .array(z.string()) - .optional() - .describe("Tool names to grant (e.g. Read, Grep, Bash, Edit). Repeatable."), - model: z.string().optional().describe("Model override (e.g. sonnet, opus, haiku)."), - effort: z.string().optional().describe("Effort level: low|medium|high|xhigh|max. Omit to inherit the session config."), - title: z.string().optional().describe("Document title (defaults to Title Case of name)."), - force: forceField, - root: rootField, - }, - toArgs: (p) => [ - "agent", - "create", - p.name, - ...flag("--description", p.description), - ...multi("--tools", p.tools), - ...flag("--model", p.model), - ...flag("--effort", p.effort), - ...flag("--title", p.title), - ...bool("--force", p.force), - ...rootArg(p), - ], - }, - { - name: "csdd_agent_list", - title: "Agent list", - description: "List agents with their tools and descriptions.", - inputSchema: { root: rootField }, - toArgs: (p) => ["agent", "list", ...rootArg(p)], - }, - { - name: "csdd_agent_show", - title: "Agent show", - description: "Print an agent file.", - inputSchema: { name: agentName, root: rootField }, - toArgs: (p) => ["agent", "show", p.name, ...rootArg(p)], - }, - { - name: "csdd_agent_delete", - title: "Agent delete", - description: "Delete .claude/agents/.md. Requires force.", - inputSchema: { name: agentName, force: forceField, root: rootField }, - toArgs: (p) => ["agent", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], - }, -]; +import { z } from "zod"; +import { + bool, + flag, + forceField, + multi, + rootArg, + rootField, + type ToolDef, +} from "../tooldef.js"; + +const agentName = z.string().describe("Agent name (.claude/agents/.md)."); + +export const agentTools: ToolDef[] = [ + { + name: "csdd_agent_create", + title: "Agent create", + description: + "Create a custom sub-agent with least-privilege tools (default: Read, Grep). Description tells the orchestrator when to pick it.", + inputSchema: { + name: agentName, + description: z.string().describe("When the orchestrator should select this agent."), + tools: z + .array(z.string()) + .optional() + .describe("Tool names to grant (e.g. Read, Grep, Bash, Edit). Repeatable."), + model: z.string().optional().describe("Model override (e.g. sonnet, opus, haiku)."), + effort: z.string().optional().describe("Effort level: low|medium|high|xhigh|max. Omit to inherit the session config."), + title: z.string().optional().describe("Document title (defaults to Title Case of name)."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "agent", + "create", + p.name, + ...flag("--description", p.description), + ...multi("--tools", p.tools), + ...flag("--model", p.model), + ...flag("--effort", p.effort), + ...flag("--title", p.title), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_agent_list", + title: "Agent list", + description: "List agents with their tools and descriptions.", + inputSchema: { root: rootField }, + toArgs: (p) => ["agent", "list", ...rootArg(p)], + }, + { + name: "csdd_agent_show", + title: "Agent show", + description: "Print an agent file.", + inputSchema: { name: agentName, root: rootField }, + toArgs: (p) => ["agent", "show", p.name, ...rootArg(p)], + }, + { + name: "csdd_agent_delete", + title: "Agent delete", + description: "Delete .claude/agents/.md. Requires force.", + inputSchema: { name: agentName, force: forceField, root: rootField }, + toArgs: (p) => ["agent", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/skill.ts b/mcp-server/src/tools/skill.ts index 765f781..4171067 100644 --- a/mcp-server/src/tools/skill.ts +++ b/mcp-server/src/tools/skill.ts @@ -1,76 +1,76 @@ -import { z } from "zod"; -import { bool, flag, forceField, rootArg, rootField, type ToolDef } from "../tooldef.js"; - -const skillName = z.string().describe("Skill name (.claude/skills//)."); - -function addArtifact(action: string, kind: string): ToolDef { - return { - name: `csdd_skill_add_${action.replace("add-", "")}`, - title: `Skill add ${kind}`, - description: `Add a ${kind} file under .claude/skills//${kind}s/ with a placeholder. Path traversal is rejected.`, - inputSchema: { - skill: skillName, - file: z.string().describe(`File path relative to the ${kind}s/ subdir.`), - root: rootField, - }, - toArgs: (p) => ["skill", action, p.skill, p.file, ...rootArg(p)], - }; -} - -export const skillTools: ToolDef[] = [ - { - name: "csdd_skill_create", - title: "Skill create", - description: - "Create .claude/skills// with SKILL.md (+ references/, assets/, scripts/). Description is the one-line activation trigger.", - inputSchema: { - name: skillName, - description: z.string().describe("One-sentence activation trigger for the skill."), - title: z.string().optional().describe("Document title (defaults to Title Case of name)."), - model: z.string().optional().describe("Model override (e.g. sonnet, opus, haiku). Omit to inherit the session config."), - effort: z.string().optional().describe("Effort level: low|medium|high|xhigh|max. Omit to inherit the session config."), - root: rootField, - }, - toArgs: (p) => [ - "skill", - "create", - p.name, - ...flag("--description", p.description), - ...flag("--model", p.model), - ...flag("--effort", p.effort), - ...flag("--title", p.title), - ...rootArg(p), - ], - }, - { - name: "csdd_skill_list", - title: "Skill list", - description: "List skills with their descriptions.", - inputSchema: { root: rootField }, - toArgs: (p) => ["skill", "list", ...rootArg(p)], - }, - { - name: "csdd_skill_show", - title: "Skill show", - description: "List a skill's files and print SKILL.md.", - inputSchema: { name: skillName, root: rootField }, - toArgs: (p) => ["skill", "show", p.name, ...rootArg(p)], - }, - addArtifact("add-reference", "reference"), - addArtifact("add-script", "script"), - addArtifact("add-asset", "asset"), - { - name: "csdd_skill_validate", - title: "Skill validate", - description: "Validate skill structure + frontmatter; report line/token counts. Exit 2 on issues.", - inputSchema: { name: skillName, root: rootField }, - toArgs: (p) => ["skill", "validate", p.name, ...rootArg(p)], - }, - { - name: "csdd_skill_delete", - title: "Skill delete", - description: "Delete .claude/skills// recursively. Requires force.", - inputSchema: { name: skillName, force: forceField, root: rootField }, - toArgs: (p) => ["skill", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], - }, -]; +import { z } from "zod"; +import { bool, flag, forceField, rootArg, rootField, type ToolDef } from "../tooldef.js"; + +const skillName = z.string().describe("Skill name (.claude/skills//)."); + +function addArtifact(action: string, kind: string): ToolDef { + return { + name: `csdd_skill_add_${action.replace("add-", "")}`, + title: `Skill add ${kind}`, + description: `Add a ${kind} file under .claude/skills//${kind}s/ with a placeholder. Path traversal is rejected.`, + inputSchema: { + skill: skillName, + file: z.string().describe(`File path relative to the ${kind}s/ subdir.`), + root: rootField, + }, + toArgs: (p) => ["skill", action, p.skill, p.file, ...rootArg(p)], + }; +} + +export const skillTools: ToolDef[] = [ + { + name: "csdd_skill_create", + title: "Skill create", + description: + "Create .claude/skills// with SKILL.md (+ references/, assets/, scripts/). Description is the one-line activation trigger.", + inputSchema: { + name: skillName, + description: z.string().describe("One-sentence activation trigger for the skill."), + title: z.string().optional().describe("Document title (defaults to Title Case of name)."), + model: z.string().optional().describe("Model override (e.g. sonnet, opus, haiku). Omit to inherit the session config."), + effort: z.string().optional().describe("Effort level: low|medium|high|xhigh|max. Omit to inherit the session config."), + root: rootField, + }, + toArgs: (p) => [ + "skill", + "create", + p.name, + ...flag("--description", p.description), + ...flag("--model", p.model), + ...flag("--effort", p.effort), + ...flag("--title", p.title), + ...rootArg(p), + ], + }, + { + name: "csdd_skill_list", + title: "Skill list", + description: "List skills with their descriptions.", + inputSchema: { root: rootField }, + toArgs: (p) => ["skill", "list", ...rootArg(p)], + }, + { + name: "csdd_skill_show", + title: "Skill show", + description: "List a skill's files and print SKILL.md.", + inputSchema: { name: skillName, root: rootField }, + toArgs: (p) => ["skill", "show", p.name, ...rootArg(p)], + }, + addArtifact("add-reference", "reference"), + addArtifact("add-script", "script"), + addArtifact("add-asset", "asset"), + { + name: "csdd_skill_validate", + title: "Skill validate", + description: "Validate skill structure + frontmatter; report line/token counts. Exit 2 on issues.", + inputSchema: { name: skillName, root: rootField }, + toArgs: (p) => ["skill", "validate", p.name, ...rootArg(p)], + }, + { + name: "csdd_skill_delete", + title: "Skill delete", + description: "Delete .claude/skills// recursively. Requires force.", + inputSchema: { name: skillName, force: forceField, root: rootField }, + toArgs: (p) => ["skill", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/spec.ts b/mcp-server/src/tools/spec.ts index dd9e114..e320dd6 100644 --- a/mcp-server/src/tools/spec.ts +++ b/mcp-server/src/tools/spec.ts @@ -1,178 +1,178 @@ -import { z } from "zod"; -import { bool, flag, forceField, rootArg, rootField, type ToolDef } from "../tooldef.js"; - -const feature = z.string().describe("Feature name (specs//)."); - -export const specTools: ToolDef[] = [ - { - name: "csdd_spec_init", - title: "Spec init", - description: - "Create specs//spec.json (phase=initialized, no approvals, not ready for implementation).", - inputSchema: { - feature, - language: z.string().optional().describe("Spec language (default: en)."), - flow: z - .enum(["unit", "tdd", "tdd-e2e"]) - .optional() - .describe("Development flow: unit (tests after code) | tdd (test-first, default) | tdd-e2e (TDD + e2e). Default: steering default, else tdd."), - root: rootField, - }, - toArgs: (p) => [ - "spec", - "init", - p.feature, - ...flag("--language", p.language), - ...flag("--flow", p.flow), - ...rootArg(p), - ], - }, - { - name: "csdd_spec_list", - title: "Spec list", - description: "List specs with current phase, approved phases, and readiness.", - inputSchema: { root: rootField }, - toArgs: (p) => ["spec", "list", ...rootArg(p)], - }, - { - name: "csdd_spec_show", - title: "Spec show", - description: "Show a spec's metadata (spec.json) and its artifacts.", - inputSchema: { feature, root: rootField }, - toArgs: (p) => ["spec", "show", p.feature, ...rootArg(p)], - }, - { - name: "csdd_spec_status", - title: "Spec status", - description: "Combined show + validate for a spec.", - inputSchema: { feature, root: rootField }, - toArgs: (p) => ["spec", "status", p.feature, ...rootArg(p)], - }, - { - name: "csdd_spec_generate", - title: "Spec generate artifact", - description: - "Generate a spec artifact. Phase gates apply: design needs requirements approved, tasks needs design approved (use force to bypass). research/bugfix are ungated.", - inputSchema: { - feature, - artifact: z - .enum(["requirements", "design", "tasks", "research", "bugfix"]) - .describe("Which artifact to generate."), - force: forceField, - root: rootField, - }, - toArgs: (p) => [ - "spec", - "generate", - p.feature, - ...flag("--artifact", p.artifact), - ...bool("--force", p.force), - ...rootArg(p), - ], - }, - { - name: "csdd_spec_approve", - title: "Spec approve phase", - description: - "Approve a spec phase (requirements|design|tasks). Validates first; force approves despite issues/missing prior approvals. Sets ready_for_implementation only when all three are approved.", - inputSchema: { - feature, - phase: z.enum(["requirements", "design", "tasks"]).describe("Phase to approve."), - force: forceField, - root: rootField, - }, - toArgs: (p) => [ - "spec", - "approve", - p.feature, - ...flag("--phase", p.phase), - ...bool("--force", p.force), - ...rootArg(p), - ], - }, - { - name: "csdd_spec_validate", - title: "Spec validate", - description: - "Validate a spec: EARS phrasing, traceability, task annotations, parallel safety. Exit 2 on issues.", - inputSchema: { feature, root: rootField }, - toArgs: (p) => ["spec", "validate", p.feature, ...rootArg(p)], - }, - { - name: "csdd_spec_delete", - title: "Spec delete", - description: "Delete specs// recursively. Requires force.", - inputSchema: { feature, force: forceField, root: rootField }, - toArgs: (p) => ["spec", "delete", p.feature, ...bool("--force", p.force), ...rootArg(p)], - }, - { - name: "csdd_spec_test_report", - title: "Spec test report", - description: - "Record per-spec unit-test evidence into specs//test-report.json (surfaced by the dashboard). With run=true it executes the tests (the per-language default command, or cmd) and parses the JUnit + coverage reports they produce into the JSON contract; lang/path auto-discover reports (python|typescript|java|go|rust); or pass an explicit junit and/or coverage file. The run exits non-zero when tests fail, so it gates the task.", - inputSchema: { - feature, - run: z - .boolean() - .optional() - .describe("Execute the tests before parsing (per-language default command, or cmd)."), - cmd: z - .string() - .optional() - .describe("Test command to execute with run=true (overrides the per-language default; validated against the language's tooling)."), - lang: z - .enum(["python", "typescript", "java", "go", "rust"]) - .optional() - .describe("Language: selects the run default command and the coverage format to auto-discover."), - path: z - .string() - .optional() - .describe("Directory to run in / discover JUnit+coverage reports under (e.g. tests/). Defaults to the workspace root."), - junit: z.string().optional().describe("Explicit JUnit XML report to parse for test counts."), - coverage: z - .string() - .optional() - .describe("Explicit coverage report to parse (lcov/Cobertura/JaCoCo/Go coverprofile)."), - root: rootField, - }, - toArgs: (p) => [ - "spec", - "test-report", - p.feature, - ...bool("--run", p.run), - ...flag("--cmd", p.cmd), - ...flag("--lang", p.lang), - ...flag("--path", p.path), - ...flag("--junit", p.junit), - ...flag("--coverage", p.coverage), - ...rootArg(p), - ], - }, - { - name: "csdd_spec_diff_report", - title: "Spec diff report", - description: - "Record a structured, auditable file diff for a spec into specs//diff-report.json (surfaced by the dashboard's Changes view). It diffs the merge-base of HEAD and the base ref → the current working tree (committed-on-branch + uncommitted changes, plus untracked files), so it tracks development as it happens. base overrides the auto-detected ref (origin/main, main, origin/master, master); maxLines caps recorded lines per file (default 2000, 0 = unlimited). Requires a git repository; read-only (never touches the index or working tree).", - inputSchema: { - feature, - base: z - .string() - .optional() - .describe("Base ref to diff against (default: auto-detect origin/main, main, origin/master, master)."), - maxLines: z - .number() - .int() - .optional() - .describe("Per-file recorded-line cap (default 2000; 0 = unlimited)."), - root: rootField, - }, - toArgs: (p) => [ - "spec", - "diff-report", - p.feature, - ...flag("--base", p.base), - ...flag("--max-lines", p.maxLines), - ...rootArg(p), - ], - }, -]; +import { z } from "zod"; +import { bool, flag, forceField, rootArg, rootField, type ToolDef } from "../tooldef.js"; + +const feature = z.string().describe("Feature name (specs//)."); + +export const specTools: ToolDef[] = [ + { + name: "csdd_spec_init", + title: "Spec init", + description: + "Create specs//spec.json (phase=initialized, no approvals, not ready for implementation).", + inputSchema: { + feature, + language: z.string().optional().describe("Spec language (default: en)."), + flow: z + .enum(["unit", "tdd", "tdd-e2e"]) + .optional() + .describe("Development flow: unit (tests after code) | tdd (test-first, default) | tdd-e2e (TDD + e2e). Default: steering default, else tdd."), + root: rootField, + }, + toArgs: (p) => [ + "spec", + "init", + p.feature, + ...flag("--language", p.language), + ...flag("--flow", p.flow), + ...rootArg(p), + ], + }, + { + name: "csdd_spec_list", + title: "Spec list", + description: "List specs with current phase, approved phases, and readiness.", + inputSchema: { root: rootField }, + toArgs: (p) => ["spec", "list", ...rootArg(p)], + }, + { + name: "csdd_spec_show", + title: "Spec show", + description: "Show a spec's metadata (spec.json) and its artifacts.", + inputSchema: { feature, root: rootField }, + toArgs: (p) => ["spec", "show", p.feature, ...rootArg(p)], + }, + { + name: "csdd_spec_status", + title: "Spec status", + description: "Combined show + validate for a spec.", + inputSchema: { feature, root: rootField }, + toArgs: (p) => ["spec", "status", p.feature, ...rootArg(p)], + }, + { + name: "csdd_spec_generate", + title: "Spec generate artifact", + description: + "Generate a spec artifact. Phase gates apply: design needs requirements approved, tasks needs design approved (use force to bypass). research/bugfix are ungated.", + inputSchema: { + feature, + artifact: z + .enum(["requirements", "design", "tasks", "research", "bugfix"]) + .describe("Which artifact to generate."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "spec", + "generate", + p.feature, + ...flag("--artifact", p.artifact), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_spec_approve", + title: "Spec approve phase", + description: + "Approve a spec phase (requirements|design|tasks). Validates first; force approves despite issues/missing prior approvals. Sets ready_for_implementation only when all three are approved.", + inputSchema: { + feature, + phase: z.enum(["requirements", "design", "tasks"]).describe("Phase to approve."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "spec", + "approve", + p.feature, + ...flag("--phase", p.phase), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_spec_validate", + title: "Spec validate", + description: + "Validate a spec: EARS phrasing, traceability, task annotations, parallel safety. Exit 2 on issues.", + inputSchema: { feature, root: rootField }, + toArgs: (p) => ["spec", "validate", p.feature, ...rootArg(p)], + }, + { + name: "csdd_spec_delete", + title: "Spec delete", + description: "Delete specs// recursively. Requires force.", + inputSchema: { feature, force: forceField, root: rootField }, + toArgs: (p) => ["spec", "delete", p.feature, ...bool("--force", p.force), ...rootArg(p)], + }, + { + name: "csdd_spec_test_report", + title: "Spec test report", + description: + "Record per-spec unit-test evidence into specs//test-report.json (surfaced by the dashboard). With run=true it executes the tests (the per-language default command, or cmd) and parses the JUnit + coverage reports they produce into the JSON contract; lang/path auto-discover reports (python|typescript|java|go|rust); or pass an explicit junit and/or coverage file. The run exits non-zero when tests fail, so it gates the task.", + inputSchema: { + feature, + run: z + .boolean() + .optional() + .describe("Execute the tests before parsing (per-language default command, or cmd)."), + cmd: z + .string() + .optional() + .describe("Test command to execute with run=true (overrides the per-language default; validated against the language's tooling)."), + lang: z + .enum(["python", "typescript", "java", "go", "rust"]) + .optional() + .describe("Language: selects the run default command and the coverage format to auto-discover."), + path: z + .string() + .optional() + .describe("Directory to run in / discover JUnit+coverage reports under (e.g. tests/). Defaults to the workspace root."), + junit: z.string().optional().describe("Explicit JUnit XML report to parse for test counts."), + coverage: z + .string() + .optional() + .describe("Explicit coverage report to parse (lcov/Cobertura/JaCoCo/Go coverprofile)."), + root: rootField, + }, + toArgs: (p) => [ + "spec", + "test-report", + p.feature, + ...bool("--run", p.run), + ...flag("--cmd", p.cmd), + ...flag("--lang", p.lang), + ...flag("--path", p.path), + ...flag("--junit", p.junit), + ...flag("--coverage", p.coverage), + ...rootArg(p), + ], + }, + { + name: "csdd_spec_diff_report", + title: "Spec diff report", + description: + "Record a structured, auditable file diff for a spec into specs//diff-report.json (surfaced by the dashboard's Changes view). It diffs the merge-base of HEAD and the base ref → the current working tree (committed-on-branch + uncommitted changes, plus untracked files), so it tracks development as it happens. base overrides the auto-detected ref (origin/main, main, origin/master, master); maxLines caps recorded lines per file (default 2000, 0 = unlimited). Requires a git repository; read-only (never touches the index or working tree).", + inputSchema: { + feature, + base: z + .string() + .optional() + .describe("Base ref to diff against (default: auto-detect origin/main, main, origin/master, master)."), + maxLines: z + .number() + .int() + .optional() + .describe("Per-file recorded-line cap (default 2000; 0 = unlimited)."), + root: rootField, + }, + toArgs: (p) => [ + "spec", + "diff-report", + p.feature, + ...flag("--base", p.base), + ...flag("--max-lines", p.maxLines), + ...rootArg(p), + ], + }, +]; diff --git a/mcp-server/src/tools/steering.ts b/mcp-server/src/tools/steering.ts index e31c9c0..b36bc1d 100644 --- a/mcp-server/src/tools/steering.ts +++ b/mcp-server/src/tools/steering.ts @@ -1,99 +1,99 @@ -import { z } from "zod"; -import { - bool, - flag, - forceField, - multi, - rootArg, - rootField, - type ToolDef, -} from "../tooldef.js"; - -const inclusion = z - .enum(["always", "fileMatch", "manual", "auto"]) - .describe( - "When the steering loads: always (always-on), fileMatch (when files match --pattern), manual (#name), auto (when description matches context).", - ); - -export const steeringTools: ToolDef[] = [ - { - name: "csdd_steering_init", - title: "Steering init", - description: - "Create .claude/steering/ and populate the 6 standard files (product, tech, structure, security, testing, api-conventions).", - inputSchema: { root: rootField }, - toArgs: (p) => ["steering", "init", ...rootArg(p)], - }, - { - name: "csdd_steering_create", - title: "Steering create", - description: - "Create a custom steering file with inclusion metadata. fileMatch requires at least one pattern; auto requires a description.", - inputSchema: { - name: z.string().describe("Steering file name (no extension)."), - inclusion, - pattern: z - .array(z.string()) - .optional() - .describe("Glob pattern(s) for fileMatch inclusion. Repeatable."), - description: z - .string() - .optional() - .describe("One-line description; required for auto inclusion."), - title: z.string().optional().describe("Document title (defaults to Title Case of name)."), - force: forceField, - root: rootField, - }, - toArgs: (p) => [ - "steering", - "create", - p.name, - ...flag("--inclusion", p.inclusion), - ...multi("--pattern", p.pattern), - ...flag("--description", p.description), - ...flag("--title", p.title), - ...bool("--force", p.force), - ...rootArg(p), - ], - }, - { - name: "csdd_steering_list", - title: "Steering list", - description: "List steering files with inclusion mode and inclusion-specific extras.", - inputSchema: { - root: rootField, - inclusion: inclusion.optional().describe("Filter by inclusion mode."), - }, - toArgs: (p) => ["steering", "list", ...rootArg(p), ...flag("--inclusion", p.inclusion)], - }, - { - name: "csdd_steering_show", - title: "Steering show", - description: "Print a steering file (frontmatter + body).", - inputSchema: { name: z.string().describe("Steering file name."), root: rootField }, - toArgs: (p) => ["steering", "show", p.name, ...rootArg(p)], - }, - { - name: "csdd_steering_delete", - title: "Steering delete", - description: - "Delete a steering file. Requires force. Foundational files (product, tech, structure) are protected.", - inputSchema: { - name: z.string().describe("Steering file name."), - force: forceField, - root: rootField, - }, - toArgs: (p) => ["steering", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], - }, - { - name: "csdd_steering_validate", - title: "Steering validate", - description: - "Validate steering frontmatter and structure. Omit name to validate all. Exit 2 on issues.", - inputSchema: { - name: z.string().optional().describe("Validate only this file; omit for all."), - root: rootField, - }, - toArgs: (p) => ["steering", "validate", ...(p.name ? [p.name] : []), ...rootArg(p)], - }, -]; +import { z } from "zod"; +import { + bool, + flag, + forceField, + multi, + rootArg, + rootField, + type ToolDef, +} from "../tooldef.js"; + +const inclusion = z + .enum(["always", "fileMatch", "manual", "auto"]) + .describe( + "When the steering loads: always (always-on), fileMatch (when files match --pattern), manual (#name), auto (when description matches context).", + ); + +export const steeringTools: ToolDef[] = [ + { + name: "csdd_steering_init", + title: "Steering init", + description: + "Create .claude/steering/ and populate the 6 standard files (product, tech, structure, security, testing, api-conventions).", + inputSchema: { root: rootField }, + toArgs: (p) => ["steering", "init", ...rootArg(p)], + }, + { + name: "csdd_steering_create", + title: "Steering create", + description: + "Create a custom steering file with inclusion metadata. fileMatch requires at least one pattern; auto requires a description.", + inputSchema: { + name: z.string().describe("Steering file name (no extension)."), + inclusion, + pattern: z + .array(z.string()) + .optional() + .describe("Glob pattern(s) for fileMatch inclusion. Repeatable."), + description: z + .string() + .optional() + .describe("One-line description; required for auto inclusion."), + title: z.string().optional().describe("Document title (defaults to Title Case of name)."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "steering", + "create", + p.name, + ...flag("--inclusion", p.inclusion), + ...multi("--pattern", p.pattern), + ...flag("--description", p.description), + ...flag("--title", p.title), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_steering_list", + title: "Steering list", + description: "List steering files with inclusion mode and inclusion-specific extras.", + inputSchema: { + root: rootField, + inclusion: inclusion.optional().describe("Filter by inclusion mode."), + }, + toArgs: (p) => ["steering", "list", ...rootArg(p), ...flag("--inclusion", p.inclusion)], + }, + { + name: "csdd_steering_show", + title: "Steering show", + description: "Print a steering file (frontmatter + body).", + inputSchema: { name: z.string().describe("Steering file name."), root: rootField }, + toArgs: (p) => ["steering", "show", p.name, ...rootArg(p)], + }, + { + name: "csdd_steering_delete", + title: "Steering delete", + description: + "Delete a steering file. Requires force. Foundational files (product, tech, structure) are protected.", + inputSchema: { + name: z.string().describe("Steering file name."), + force: forceField, + root: rootField, + }, + toArgs: (p) => ["steering", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, + { + name: "csdd_steering_validate", + title: "Steering validate", + description: + "Validate steering frontmatter and structure. Omit name to validate all. Exit 2 on issues.", + inputSchema: { + name: z.string().optional().describe("Validate only this file; omit for all."), + root: rootField, + }, + toArgs: (p) => ["steering", "validate", ...(p.name ? [p.name] : []), ...rootArg(p)], + }, +]; diff --git a/mcp-server/test/csdd-missing.test.ts b/mcp-server/test/csdd-missing.test.ts index d366380..761aac3 100644 --- a/mcp-server/test/csdd-missing.test.ts +++ b/mcp-server/test/csdd-missing.test.ts @@ -1,21 +1,21 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -// A separate file (= separate process) so resolveCsddBin's cache starts empty -// and picks up this bogus path. A spawn failure (ENOENT) must surface as a -// graceful exit-127 result with guidance, never an unhandled rejection. -const missing = join(tmpdir(), "definitely-not-a-real-csdd-binary-xyz"); -process.env.CSDD_BIN = missing; - -const { runCsdd } = await import("../dist/csdd.js"); - -test("runCsdd maps a missing binary to exit 127 with guidance", async () => { - const r = await runCsdd(["version"]); - assert.equal(r.ok, false); - assert.equal(r.exitCode, 127); - assert.match(r.stderr, /not found or not executable/); - assert.match(r.stderr, /CSDD_BIN/); - assert.match(r.stderr, new RegExp(missing.replace(/[.\\]/g, "\\$&"))); -}); +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// A separate file (= separate process) so resolveCsddBin's cache starts empty +// and picks up this bogus path. A spawn failure (ENOENT) must surface as a +// graceful exit-127 result with guidance, never an unhandled rejection. +const missing = join(tmpdir(), "definitely-not-a-real-csdd-binary-xyz"); +process.env.CSDD_BIN = missing; + +const { runCsdd } = await import("../dist/csdd.js"); + +test("runCsdd maps a missing binary to exit 127 with guidance", async () => { + const r = await runCsdd(["version"]); + assert.equal(r.ok, false); + assert.equal(r.exitCode, 127); + assert.match(r.stderr, /not found or not executable/); + assert.match(r.stderr, /CSDD_BIN/); + assert.match(r.stderr, new RegExp(missing.replace(/[.\\]/g, "\\$&"))); +}); diff --git a/mcp-server/test/csdd-run.test.ts b/mcp-server/test/csdd-run.test.ts index c212a77..b01537f 100644 --- a/mcp-server/test/csdd-run.test.ts +++ b/mcp-server/test/csdd-run.test.ts @@ -1,67 +1,67 @@ -import test, { after } from "node:test"; -import assert from "node:assert/strict"; -import { realpathSync } from "node:fs"; -import { tmpdir } from "node:os"; - -import { runCsdd, resolveCsddBin } from "../dist/csdd.js"; -import { makeFakeCsdd } from "./helpers.ts"; - -// Resolve to the stub for the whole file. CSDD_BIN is the highest-priority -// source in resolveCsddBin, and the result is cached process-wide. -const fake = makeFakeCsdd(); -process.env.CSDD_BIN = fake.bin; - -after(() => fake.cleanup()); - -test("resolveCsddBin honours the CSDD_BIN override", () => { - assert.equal(resolveCsddBin(), fake.bin); -}); - -test("resolveCsddBin caches the first resolution", () => { - process.env.CSDD_BIN = "/some/other/path"; - assert.equal(resolveCsddBin(), fake.bin, "later CSDD_BIN changes are ignored"); - process.env.CSDD_BIN = fake.bin; // restore for the runCsdd cases below -}); - -test("runCsdd captures stdout and a zero exit as ok", async () => { - const r = await runCsdd(["hello", "world"]); - assert.equal(r.ok, true); - assert.equal(r.exitCode, 0); - assert.equal(r.stdout.trim(), JSON.stringify(["hello", "world"])); - assert.equal(r.stderr, ""); -}); - -test("runCsdd reports exit 2 (validation) without rejecting", async () => { - const r = await runCsdd(["exit2"]); - assert.equal(r.ok, false); - assert.equal(r.exitCode, 2); - assert.match(r.stderr, /validation boom/); -}); - -test("runCsdd reports a generic non-zero exit", async () => { - const r = await runCsdd(["fail"]); - assert.equal(r.ok, false); - assert.equal(r.exitCode, 3); - assert.match(r.stderr, /kaboom/); -}); - -test("runCsdd handles a silent success", async () => { - const r = await runCsdd(["silent"]); - assert.equal(r.ok, true); - assert.equal(r.stdout, ""); - assert.equal(r.stderr, ""); -}); - -test("runCsdd keeps stderr alongside stdout on success", async () => { - const r = await runCsdd(["stderr-ok"]); - assert.equal(r.ok, true); - assert.match(r.stdout, /out line/); - assert.match(r.stderr, /warn line/); -}); - -test("runCsdd forces NO_COLOR and runs in the given cwd", async () => { - const cwd = realpathSync(tmpdir()); - const r = await runCsdd(["env"], cwd); - assert.match(r.stdout, /NO_COLOR=1/); - assert.match(r.stdout, new RegExp(`CWD=${cwd}(\\n|$)`)); -}); +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { runCsdd, resolveCsddBin } from "../dist/csdd.js"; +import { makeFakeCsdd } from "./helpers.ts"; + +// Resolve to the stub for the whole file. CSDD_BIN is the highest-priority +// source in resolveCsddBin, and the result is cached process-wide. +const fake = makeFakeCsdd(); +process.env.CSDD_BIN = fake.bin; + +after(() => fake.cleanup()); + +test("resolveCsddBin honours the CSDD_BIN override", () => { + assert.equal(resolveCsddBin(), fake.bin); +}); + +test("resolveCsddBin caches the first resolution", () => { + process.env.CSDD_BIN = "/some/other/path"; + assert.equal(resolveCsddBin(), fake.bin, "later CSDD_BIN changes are ignored"); + process.env.CSDD_BIN = fake.bin; // restore for the runCsdd cases below +}); + +test("runCsdd captures stdout and a zero exit as ok", async () => { + const r = await runCsdd(["hello", "world"]); + assert.equal(r.ok, true); + assert.equal(r.exitCode, 0); + assert.equal(r.stdout.trim(), JSON.stringify(["hello", "world"])); + assert.equal(r.stderr, ""); +}); + +test("runCsdd reports exit 2 (validation) without rejecting", async () => { + const r = await runCsdd(["exit2"]); + assert.equal(r.ok, false); + assert.equal(r.exitCode, 2); + assert.match(r.stderr, /validation boom/); +}); + +test("runCsdd reports a generic non-zero exit", async () => { + const r = await runCsdd(["fail"]); + assert.equal(r.ok, false); + assert.equal(r.exitCode, 3); + assert.match(r.stderr, /kaboom/); +}); + +test("runCsdd handles a silent success", async () => { + const r = await runCsdd(["silent"]); + assert.equal(r.ok, true); + assert.equal(r.stdout, ""); + assert.equal(r.stderr, ""); +}); + +test("runCsdd keeps stderr alongside stdout on success", async () => { + const r = await runCsdd(["stderr-ok"]); + assert.equal(r.ok, true); + assert.match(r.stdout, /out line/); + assert.match(r.stderr, /warn line/); +}); + +test("runCsdd forces NO_COLOR and runs in the given cwd", async () => { + const cwd = realpathSync(tmpdir()); + const r = await runCsdd(["env"], cwd); + assert.match(r.stdout, /NO_COLOR=1/); + assert.match(r.stdout, new RegExp(`CWD=${cwd}(\\n|$)`)); +}); diff --git a/mcp-server/test/handler.test.ts b/mcp-server/test/handler.test.ts index 6cd78de..d9276cb 100644 --- a/mcp-server/test/handler.test.ts +++ b/mcp-server/test/handler.test.ts @@ -1,58 +1,58 @@ -import test, { before, after } from "node:test"; -import assert from "node:assert/strict"; - -import { makeHandler, type ToolDef } from "../dist/tooldef.js"; -import { makeFakeCsdd, type FakeCsdd } from "./helpers.ts"; - -// makeHandler wires toArgs -> runCsdd -> toMcpResult. We point CSDD_BIN at the -// stub so the full chain runs without a real csdd build. resolveCsddBin caches -// on first use, so every handler in this process resolves to the same stub. -let fake: FakeCsdd; - -before(() => { - fake = makeFakeCsdd(); - process.env.CSDD_BIN = fake.bin; -}); - -after(() => fake.cleanup()); - -function defWith(toArgs: ToolDef["toArgs"]): ToolDef { - return { - name: "csdd_test", - title: "test", - description: "test", - inputSchema: {}, - toArgs, - }; -} - -test("handler runs the built argv and echoes stdout back as text", async () => { - const handler = makeHandler(defWith((p) => ["echo", p.feature])); - const res = await handler({ feature: "albums" }); - assert.equal(res.isError, false); - assert.equal(res.content[0].text, JSON.stringify(["echo", "albums"])); -}); - -test("handler surfaces a validation failure (exit 2) as an MCP error", async () => { - const handler = makeHandler(defWith(() => ["exit2"])); - const res = await handler({}); - assert.equal(res.isError, true); - assert.match(res.content[0].text, /validation failed \(exit 2\)/); -}); - -test("handler tolerates being invoked with no params object", async () => { - const handler = makeHandler(defWith(() => ["silent"])); - const res = await handler(undefined as unknown as Record); - assert.equal(res.isError, false); - assert.equal(res.content[0].text, "(ok, no output)"); -}); - -test("handler runs csdd with params.root as the working directory", async () => { - const handler = makeHandler(defWith(() => ["env"])); - // Stub's "env" mode prints its cwd; the temp dir holding the stub is a real - // directory we can assert against. - const root = fake.bin.replace(/\/[^/]+$/, ""); - const res = await handler({ root }); - assert.match(res.content[0].text, new RegExp(`CWD=${root}(\\n|$)`)); - assert.match(res.content[0].text, /NO_COLOR=1/); -}); +import test, { before, after } from "node:test"; +import assert from "node:assert/strict"; + +import { makeHandler, type ToolDef } from "../dist/tooldef.js"; +import { makeFakeCsdd, type FakeCsdd } from "./helpers.ts"; + +// makeHandler wires toArgs -> runCsdd -> toMcpResult. We point CSDD_BIN at the +// stub so the full chain runs without a real csdd build. resolveCsddBin caches +// on first use, so every handler in this process resolves to the same stub. +let fake: FakeCsdd; + +before(() => { + fake = makeFakeCsdd(); + process.env.CSDD_BIN = fake.bin; +}); + +after(() => fake.cleanup()); + +function defWith(toArgs: ToolDef["toArgs"]): ToolDef { + return { + name: "csdd_test", + title: "test", + description: "test", + inputSchema: {}, + toArgs, + }; +} + +test("handler runs the built argv and echoes stdout back as text", async () => { + const handler = makeHandler(defWith((p) => ["echo", p.feature])); + const res = await handler({ feature: "albums" }); + assert.equal(res.isError, false); + assert.equal(res.content[0].text, JSON.stringify(["echo", "albums"])); +}); + +test("handler surfaces a validation failure (exit 2) as an MCP error", async () => { + const handler = makeHandler(defWith(() => ["exit2"])); + const res = await handler({}); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /validation failed \(exit 2\)/); +}); + +test("handler tolerates being invoked with no params object", async () => { + const handler = makeHandler(defWith(() => ["silent"])); + const res = await handler(undefined as unknown as Record); + assert.equal(res.isError, false); + assert.equal(res.content[0].text, "(ok, no output)"); +}); + +test("handler runs csdd with params.root as the working directory", async () => { + const handler = makeHandler(defWith(() => ["env"])); + // Stub's "env" mode prints its cwd; the temp dir holding the stub is a real + // directory we can assert against. + const root = fake.bin.replace(/\/[^/]+$/, ""); + const res = await handler({ root }); + assert.match(res.content[0].text, new RegExp(`CWD=${root}(\\n|$)`)); + assert.match(res.content[0].text, /NO_COLOR=1/); +}); diff --git a/mcp-server/test/helpers.ts b/mcp-server/test/helpers.ts index a649619..ee0c6ce 100644 --- a/mcp-server/test/helpers.ts +++ b/mcp-server/test/helpers.ts @@ -1,57 +1,57 @@ -// Test helpers. The MCP server shells out to a `csdd` binary; rather than -// depend on a real build, these tests point CSDD_BIN at a tiny Node stub that -// mimics the bits of csdd behaviour the server cares about (exit codes, -// stdout/stderr, and that NO_COLOR / cwd are passed through). -import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -// The stub dispatches on its first argument so a single cached binary can cover -// every runCsdd scenario: -// exit2 -> stderr + exit 2 (validation failure) -// fail -> stderr + exit 3 (generic failure) -// silent -> exit 0, no output -// stderr-ok -> stdout + stderr, exit 0 (warning on success) -// env -> print NO_COLOR and cwd so the caller can assert them -// -> echo the full argv as JSON, exit 0 -const STUB_SRC = `#!/usr/bin/env node -const args = process.argv.slice(2); -const mode = args[0]; -switch (mode) { - case "exit2": - process.stderr.write("validation boom\\n"); - process.exit(2); - case "fail": - process.stderr.write("kaboom\\n"); - process.exit(3); - case "silent": - process.exit(0); - case "stderr-ok": - process.stdout.write("out line\\n"); - process.stderr.write("warn line\\n"); - process.exit(0); - case "env": - process.stdout.write("NO_COLOR=" + (process.env.NO_COLOR ?? "") + "\\n"); - process.stdout.write("CWD=" + process.cwd() + "\\n"); - process.exit(0); - default: - process.stdout.write(JSON.stringify(args) + "\\n"); - process.exit(0); -} -`; - -export interface FakeCsdd { - /** Absolute path to the executable stub. */ - bin: string; - /** Remove the temp directory holding the stub. */ - cleanup: () => void; -} - -/** Write an executable csdd stub to a fresh temp dir and return its path. */ -export function makeFakeCsdd(): FakeCsdd { - const dir = mkdtempSync(join(tmpdir(), "csdd-mcp-test-")); - const bin = join(dir, "fake-csdd.mjs"); - writeFileSync(bin, STUB_SRC); - chmodSync(bin, 0o755); - return { bin, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; -} +// Test helpers. The MCP server shells out to a `csdd` binary; rather than +// depend on a real build, these tests point CSDD_BIN at a tiny Node stub that +// mimics the bits of csdd behaviour the server cares about (exit codes, +// stdout/stderr, and that NO_COLOR / cwd are passed through). +import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// The stub dispatches on its first argument so a single cached binary can cover +// every runCsdd scenario: +// exit2 -> stderr + exit 2 (validation failure) +// fail -> stderr + exit 3 (generic failure) +// silent -> exit 0, no output +// stderr-ok -> stdout + stderr, exit 0 (warning on success) +// env -> print NO_COLOR and cwd so the caller can assert them +// -> echo the full argv as JSON, exit 0 +const STUB_SRC = `#!/usr/bin/env node +const args = process.argv.slice(2); +const mode = args[0]; +switch (mode) { + case "exit2": + process.stderr.write("validation boom\\n"); + process.exit(2); + case "fail": + process.stderr.write("kaboom\\n"); + process.exit(3); + case "silent": + process.exit(0); + case "stderr-ok": + process.stdout.write("out line\\n"); + process.stderr.write("warn line\\n"); + process.exit(0); + case "env": + process.stdout.write("NO_COLOR=" + (process.env.NO_COLOR ?? "") + "\\n"); + process.stdout.write("CWD=" + process.cwd() + "\\n"); + process.exit(0); + default: + process.stdout.write(JSON.stringify(args) + "\\n"); + process.exit(0); +} +`; + +export interface FakeCsdd { + /** Absolute path to the executable stub. */ + bin: string; + /** Remove the temp directory holding the stub. */ + cleanup: () => void; +} + +/** Write an executable csdd stub to a fresh temp dir and return its path. */ +export function makeFakeCsdd(): FakeCsdd { + const dir = mkdtempSync(join(tmpdir(), "csdd-mcp-test-")); + const bin = join(dir, "fake-csdd.mjs"); + writeFileSync(bin, STUB_SRC); + chmodSync(bin, 0o755); + return { bin, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} diff --git a/mcp-server/test/tooldef.test.ts b/mcp-server/test/tooldef.test.ts index 3c5965a..7a58ced 100644 --- a/mcp-server/test/tooldef.test.ts +++ b/mcp-server/test/tooldef.test.ts @@ -1,103 +1,103 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - flag, - bool, - multi, - rootArg, - toMcpResult, -} from "../dist/tooldef.js"; -import type { CsddResult } from "../dist/csdd.js"; - -// --- argv builders --------------------------------------------------------- - -test("flag emits --name value for non-empty values", () => { - assert.deepEqual(flag("--language", "pt"), ["--language", "pt"]); - assert.deepEqual(flag("--n", 0), ["--n", "0"], "0 is a real value, not empty"); - assert.deepEqual(flag("--n", false), ["--n", "false"]); -}); - -test("flag omits empty / null / undefined", () => { - assert.deepEqual(flag("--x", ""), []); - assert.deepEqual(flag("--x", null), []); - assert.deepEqual(flag("--x", undefined), []); -}); - -test("bool emits the flag only when truthy", () => { - assert.deepEqual(bool("--force", true), ["--force"]); - assert.deepEqual(bool("--force", false), []); - assert.deepEqual(bool("--force", undefined), []); -}); - -test("multi repeats --name per array item, stringifying", () => { - assert.deepEqual(multi("--tools", ["Read", "Grep"]), [ - "--tools", - "Read", - "--tools", - "Grep", - ]); - assert.deepEqual(multi("--arg", [1, 2]), ["--arg", "1", "--arg", "2"]); -}); - -test("multi yields nothing for non-arrays / empty", () => { - assert.deepEqual(multi("--x", undefined), []); - assert.deepEqual(multi("--x", "scalar"), []); - assert.deepEqual(multi("--x", []), []); -}); - -test("rootArg passes --root through only when set", () => { - assert.deepEqual(rootArg({ root: "/proj" }), ["--root", "/proj"]); - assert.deepEqual(rootArg({}), []); - assert.deepEqual(rootArg({ root: "" }), []); -}); - -// --- result formatting ----------------------------------------------------- - -const ok = (over: Partial = {}): CsddResult => ({ - ok: true, - exitCode: 0, - stdout: "", - stderr: "", - ...over, -}); - -test("toMcpResult: success with stdout", () => { - const r = toMcpResult(ok({ stdout: "all good\n" })); - assert.equal(r.isError, false); - assert.equal(r.content[0].text, "all good"); -}); - -test("toMcpResult: success with no output gets a friendly placeholder", () => { - const r = toMcpResult(ok()); - assert.equal(r.isError, false); - assert.equal(r.content[0].text, "(ok, no output)"); -}); - -test("toMcpResult: success keeps stderr unlabelled (warnings)", () => { - const r = toMcpResult(ok({ stdout: "done", stderr: "heads up" })); - assert.equal(r.isError, false); - assert.match(r.content[0].text, /done/); - assert.match(r.content[0].text, /heads up/); - assert.doesNotMatch(r.content[0].text, /\[stderr\]/); -}); - -test("toMcpResult: exit 2 is flagged as a validation failure", () => { - const r = toMcpResult(ok({ ok: false, exitCode: 2, stderr: "bad EARS" })); - assert.equal(r.isError, true); - assert.match(r.content[0].text, /^csdd validation failed \(exit 2\):/); - assert.match(r.content[0].text, /\[stderr\] bad EARS/); -}); - -test("toMcpResult: generic non-zero exit", () => { - const r = toMcpResult(ok({ ok: false, exitCode: 3, stderr: "boom" })); - assert.equal(r.isError, true); - assert.match(r.content[0].text, /^csdd failed \(exit 3\):/); - assert.match(r.content[0].text, /\[stderr\] boom/); -}); - -test("toMcpResult: failure with no output names the exit code", () => { - const r = toMcpResult(ok({ ok: false, exitCode: 1 })); - assert.equal(r.isError, true); - assert.match(r.content[0].text, /csdd exited with code 1/); -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + flag, + bool, + multi, + rootArg, + toMcpResult, +} from "../dist/tooldef.js"; +import type { CsddResult } from "../dist/csdd.js"; + +// --- argv builders --------------------------------------------------------- + +test("flag emits --name value for non-empty values", () => { + assert.deepEqual(flag("--language", "pt"), ["--language", "pt"]); + assert.deepEqual(flag("--n", 0), ["--n", "0"], "0 is a real value, not empty"); + assert.deepEqual(flag("--n", false), ["--n", "false"]); +}); + +test("flag omits empty / null / undefined", () => { + assert.deepEqual(flag("--x", ""), []); + assert.deepEqual(flag("--x", null), []); + assert.deepEqual(flag("--x", undefined), []); +}); + +test("bool emits the flag only when truthy", () => { + assert.deepEqual(bool("--force", true), ["--force"]); + assert.deepEqual(bool("--force", false), []); + assert.deepEqual(bool("--force", undefined), []); +}); + +test("multi repeats --name per array item, stringifying", () => { + assert.deepEqual(multi("--tools", ["Read", "Grep"]), [ + "--tools", + "Read", + "--tools", + "Grep", + ]); + assert.deepEqual(multi("--arg", [1, 2]), ["--arg", "1", "--arg", "2"]); +}); + +test("multi yields nothing for non-arrays / empty", () => { + assert.deepEqual(multi("--x", undefined), []); + assert.deepEqual(multi("--x", "scalar"), []); + assert.deepEqual(multi("--x", []), []); +}); + +test("rootArg passes --root through only when set", () => { + assert.deepEqual(rootArg({ root: "/proj" }), ["--root", "/proj"]); + assert.deepEqual(rootArg({}), []); + assert.deepEqual(rootArg({ root: "" }), []); +}); + +// --- result formatting ----------------------------------------------------- + +const ok = (over: Partial = {}): CsddResult => ({ + ok: true, + exitCode: 0, + stdout: "", + stderr: "", + ...over, +}); + +test("toMcpResult: success with stdout", () => { + const r = toMcpResult(ok({ stdout: "all good\n" })); + assert.equal(r.isError, false); + assert.equal(r.content[0].text, "all good"); +}); + +test("toMcpResult: success with no output gets a friendly placeholder", () => { + const r = toMcpResult(ok()); + assert.equal(r.isError, false); + assert.equal(r.content[0].text, "(ok, no output)"); +}); + +test("toMcpResult: success keeps stderr unlabelled (warnings)", () => { + const r = toMcpResult(ok({ stdout: "done", stderr: "heads up" })); + assert.equal(r.isError, false); + assert.match(r.content[0].text, /done/); + assert.match(r.content[0].text, /heads up/); + assert.doesNotMatch(r.content[0].text, /\[stderr\]/); +}); + +test("toMcpResult: exit 2 is flagged as a validation failure", () => { + const r = toMcpResult(ok({ ok: false, exitCode: 2, stderr: "bad EARS" })); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /^csdd validation failed \(exit 2\):/); + assert.match(r.content[0].text, /\[stderr\] bad EARS/); +}); + +test("toMcpResult: generic non-zero exit", () => { + const r = toMcpResult(ok({ ok: false, exitCode: 3, stderr: "boom" })); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /^csdd failed \(exit 3\):/); + assert.match(r.content[0].text, /\[stderr\] boom/); +}); + +test("toMcpResult: failure with no output names the exit code", () => { + const r = toMcpResult(ok({ ok: false, exitCode: 1 })); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /csdd exited with code 1/); +}); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 657d5fe..db466a3 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -1,153 +1,153 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { allTools, miscTools } from "../dist/registry.js"; -import type { ToolDef } from "../dist/tooldef.js"; - -const byName = new Map(allTools.map((t) => [t.name, t] as const)); - -function argv(name: string, params: Record): string[] { - const def = byName.get(name); - assert.ok(def, `tool ${name} is registered`); - return (def as ToolDef).toArgs(params); -} - -// Every tool, with a representative set of params, mapped to the exact csdd -// argv it should produce. Keeping this exhaustive is the point: the coverage -// test below fails if a registered tool is missing from this table. -const CASES: Array<[name: string, params: Record, expected: string[]]> = [ - // misc - ["csdd_version", {}, ["version"]], - - // steering - ["csdd_steering_init", {}, ["steering", "init"]], - ["csdd_steering_init", { root: "/p" }, ["steering", "init", "--root", "/p"]], - [ - "csdd_steering_create", - { name: "obs", inclusion: "auto", description: "d" }, - ["steering", "create", "obs", "--inclusion", "auto", "--description", "d"], - ], - [ - "csdd_steering_create", - { name: "api", inclusion: "fileMatch", pattern: ["a", "b"], title: "API", force: true, root: "/p" }, - ["steering", "create", "api", "--inclusion", "fileMatch", "--pattern", "a", "--pattern", "b", "--title", "API", "--force", "--root", "/p"], - ], - ["csdd_steering_list", {}, ["steering", "list"]], - ["csdd_steering_list", { inclusion: "always", root: "/p" }, ["steering", "list", "--root", "/p", "--inclusion", "always"]], - ["csdd_steering_show", { name: "tech" }, ["steering", "show", "tech"]], - ["csdd_steering_delete", { name: "x" }, ["steering", "delete", "x"]], - ["csdd_steering_delete", { name: "x", force: true, root: "/p" }, ["steering", "delete", "x", "--force", "--root", "/p"]], - ["csdd_steering_validate", {}, ["steering", "validate"]], - ["csdd_steering_validate", { name: "tech", root: "/p" }, ["steering", "validate", "tech", "--root", "/p"]], - - // spec - ["csdd_spec_init", { feature: "f" }, ["spec", "init", "f"]], - ["csdd_spec_init", { feature: "f", language: "pt", root: "/p" }, ["spec", "init", "f", "--language", "pt", "--root", "/p"]], - ["csdd_spec_list", {}, ["spec", "list"]], - ["csdd_spec_show", { feature: "f" }, ["spec", "show", "f"]], - ["csdd_spec_status", { feature: "f" }, ["spec", "status", "f"]], - ["csdd_spec_generate", { feature: "f", artifact: "design" }, ["spec", "generate", "f", "--artifact", "design"]], - ["csdd_spec_generate", { feature: "f", artifact: "tasks", force: true, root: "/p" }, ["spec", "generate", "f", "--artifact", "tasks", "--force", "--root", "/p"]], - ["csdd_spec_approve", { feature: "f", phase: "requirements" }, ["spec", "approve", "f", "--phase", "requirements"]], - ["csdd_spec_approve", { feature: "f", phase: "design", force: true }, ["spec", "approve", "f", "--phase", "design", "--force"]], - ["csdd_spec_validate", { feature: "f" }, ["spec", "validate", "f"]], - ["csdd_spec_delete", { feature: "f", force: true }, ["spec", "delete", "f", "--force"]], - [ - "csdd_spec_test_report", - { feature: "f", lang: "go", path: "tests/" }, - ["spec", "test-report", "f", "--lang", "go", "--path", "tests/"], - ], - [ - "csdd_spec_test_report", - { feature: "f", run: true, cmd: "go test ./...", junit: "junit.xml", coverage: "coverage.out", root: "/p" }, - ["spec", "test-report", "f", "--run", "--cmd", "go test ./...", "--junit", "junit.xml", "--coverage", "coverage.out", "--root", "/p"], - ], - ["csdd_spec_diff_report", { feature: "f" }, ["spec", "diff-report", "f"]], - [ - "csdd_spec_diff_report", - { feature: "f", base: "main", maxLines: 500, root: "/p" }, - ["spec", "diff-report", "f", "--base", "main", "--max-lines", "500", "--root", "/p"], - ], - - // skill - ["csdd_skill_create", { name: "s", description: "d" }, ["skill", "create", "s", "--description", "d"]], - ["csdd_skill_create", { name: "s", description: "d", title: "S", root: "/p" }, ["skill", "create", "s", "--description", "d", "--title", "S", "--root", "/p"]], - [ - "csdd_skill_create", - { name: "s", description: "d", model: "opus", effort: "low" }, - ["skill", "create", "s", "--description", "d", "--model", "opus", "--effort", "low"], - ], - ["csdd_skill_list", {}, ["skill", "list"]], - ["csdd_skill_show", { name: "s" }, ["skill", "show", "s"]], - ["csdd_skill_add_reference", { skill: "s", file: "r.md" }, ["skill", "add-reference", "s", "r.md"]], - ["csdd_skill_add_script", { skill: "s", file: "x.sh", root: "/p" }, ["skill", "add-script", "s", "x.sh", "--root", "/p"]], - ["csdd_skill_add_asset", { skill: "s", file: "a.png" }, ["skill", "add-asset", "s", "a.png"]], - ["csdd_skill_validate", { name: "s" }, ["skill", "validate", "s"]], - ["csdd_skill_delete", { name: "s", force: true }, ["skill", "delete", "s", "--force"]], - - // agent - ["csdd_agent_create", { name: "rev", description: "d" }, ["agent", "create", "rev", "--description", "d"]], - [ - "csdd_agent_create", - { name: "rev", description: "d", tools: ["Read", "Grep"], model: "opus", title: "Rev", force: true, root: "/p" }, - ["agent", "create", "rev", "--description", "d", "--tools", "Read", "--tools", "Grep", "--model", "opus", "--title", "Rev", "--force", "--root", "/p"], - ], - [ - "csdd_agent_create", - { name: "rev", description: "d", effort: "high" }, - ["agent", "create", "rev", "--description", "d", "--effort", "high"], - ], - [ - "csdd_agent_create", - { name: "rev", description: "d", model: "opus", effort: "max" }, - ["agent", "create", "rev", "--description", "d", "--model", "opus", "--effort", "max"], - ], - ["csdd_agent_list", {}, ["agent", "list"]], - ["csdd_agent_show", { name: "rev" }, ["agent", "show", "rev"]], - ["csdd_agent_delete", { name: "rev", force: true }, ["agent", "delete", "rev", "--force"]], -]; - -for (const [name, params, expected] of CASES) { - test(`toArgs ${name} ${JSON.stringify(params)}`, () => { - assert.deepEqual(argv(name, params), expected); - }); -} - -// --- registry invariants --------------------------------------------------- - -test("every registered tool is exercised by the CASES table", () => { - const covered = new Set(CASES.map(([name]) => name)); - const missing = allTools.map((t) => t.name).filter((n) => !covered.has(n)); - assert.deepEqual(missing, [], "tools missing a toArgs test case"); -}); - -test("tool names are unique", () => { - const names = allTools.map((t) => t.name); - assert.equal(new Set(names).size, names.length); -}); - -test("every tool has the required, well-formed fields", () => { - for (const t of allTools) { - assert.match(t.name, /^csdd(_[a-z]+)+$/, `name shape: ${t.name}`); - assert.ok(t.title && t.title.length > 0, `${t.name} has a title`); - assert.ok(t.description && t.description.length > 0, `${t.name} has a description`); - assert.equal(typeof t.inputSchema, "object", `${t.name} inputSchema is an object`); - assert.equal(typeof t.toArgs, "function", `${t.name} toArgs is a function`); - } -}); - -test("miscTools are included in allTools", () => { - for (const m of miscTools) { - assert.ok(byName.has(m.name), `${m.name} present in allTools`); - } -}); - -test("the first argv token is always a known csdd resource/command", () => { - const roots = new Set(["version", "steering", "spec", "skill", "agent"]); - for (const t of allTools) { - // Call with empty params; we only inspect the leading command token, which - // never depends on user input. - const head = t.toArgs({})[0]; - assert.ok(roots.has(head), `${t.name} -> ${head}`); - } -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +import { allTools, miscTools } from "../dist/registry.js"; +import type { ToolDef } from "../dist/tooldef.js"; + +const byName = new Map(allTools.map((t) => [t.name, t] as const)); + +function argv(name: string, params: Record): string[] { + const def = byName.get(name); + assert.ok(def, `tool ${name} is registered`); + return (def as ToolDef).toArgs(params); +} + +// Every tool, with a representative set of params, mapped to the exact csdd +// argv it should produce. Keeping this exhaustive is the point: the coverage +// test below fails if a registered tool is missing from this table. +const CASES: Array<[name: string, params: Record, expected: string[]]> = [ + // misc + ["csdd_version", {}, ["version"]], + + // steering + ["csdd_steering_init", {}, ["steering", "init"]], + ["csdd_steering_init", { root: "/p" }, ["steering", "init", "--root", "/p"]], + [ + "csdd_steering_create", + { name: "obs", inclusion: "auto", description: "d" }, + ["steering", "create", "obs", "--inclusion", "auto", "--description", "d"], + ], + [ + "csdd_steering_create", + { name: "api", inclusion: "fileMatch", pattern: ["a", "b"], title: "API", force: true, root: "/p" }, + ["steering", "create", "api", "--inclusion", "fileMatch", "--pattern", "a", "--pattern", "b", "--title", "API", "--force", "--root", "/p"], + ], + ["csdd_steering_list", {}, ["steering", "list"]], + ["csdd_steering_list", { inclusion: "always", root: "/p" }, ["steering", "list", "--root", "/p", "--inclusion", "always"]], + ["csdd_steering_show", { name: "tech" }, ["steering", "show", "tech"]], + ["csdd_steering_delete", { name: "x" }, ["steering", "delete", "x"]], + ["csdd_steering_delete", { name: "x", force: true, root: "/p" }, ["steering", "delete", "x", "--force", "--root", "/p"]], + ["csdd_steering_validate", {}, ["steering", "validate"]], + ["csdd_steering_validate", { name: "tech", root: "/p" }, ["steering", "validate", "tech", "--root", "/p"]], + + // spec + ["csdd_spec_init", { feature: "f" }, ["spec", "init", "f"]], + ["csdd_spec_init", { feature: "f", language: "pt", root: "/p" }, ["spec", "init", "f", "--language", "pt", "--root", "/p"]], + ["csdd_spec_list", {}, ["spec", "list"]], + ["csdd_spec_show", { feature: "f" }, ["spec", "show", "f"]], + ["csdd_spec_status", { feature: "f" }, ["spec", "status", "f"]], + ["csdd_spec_generate", { feature: "f", artifact: "design" }, ["spec", "generate", "f", "--artifact", "design"]], + ["csdd_spec_generate", { feature: "f", artifact: "tasks", force: true, root: "/p" }, ["spec", "generate", "f", "--artifact", "tasks", "--force", "--root", "/p"]], + ["csdd_spec_approve", { feature: "f", phase: "requirements" }, ["spec", "approve", "f", "--phase", "requirements"]], + ["csdd_spec_approve", { feature: "f", phase: "design", force: true }, ["spec", "approve", "f", "--phase", "design", "--force"]], + ["csdd_spec_validate", { feature: "f" }, ["spec", "validate", "f"]], + ["csdd_spec_delete", { feature: "f", force: true }, ["spec", "delete", "f", "--force"]], + [ + "csdd_spec_test_report", + { feature: "f", lang: "go", path: "tests/" }, + ["spec", "test-report", "f", "--lang", "go", "--path", "tests/"], + ], + [ + "csdd_spec_test_report", + { feature: "f", run: true, cmd: "go test ./...", junit: "junit.xml", coverage: "coverage.out", root: "/p" }, + ["spec", "test-report", "f", "--run", "--cmd", "go test ./...", "--junit", "junit.xml", "--coverage", "coverage.out", "--root", "/p"], + ], + ["csdd_spec_diff_report", { feature: "f" }, ["spec", "diff-report", "f"]], + [ + "csdd_spec_diff_report", + { feature: "f", base: "main", maxLines: 500, root: "/p" }, + ["spec", "diff-report", "f", "--base", "main", "--max-lines", "500", "--root", "/p"], + ], + + // skill + ["csdd_skill_create", { name: "s", description: "d" }, ["skill", "create", "s", "--description", "d"]], + ["csdd_skill_create", { name: "s", description: "d", title: "S", root: "/p" }, ["skill", "create", "s", "--description", "d", "--title", "S", "--root", "/p"]], + [ + "csdd_skill_create", + { name: "s", description: "d", model: "opus", effort: "low" }, + ["skill", "create", "s", "--description", "d", "--model", "opus", "--effort", "low"], + ], + ["csdd_skill_list", {}, ["skill", "list"]], + ["csdd_skill_show", { name: "s" }, ["skill", "show", "s"]], + ["csdd_skill_add_reference", { skill: "s", file: "r.md" }, ["skill", "add-reference", "s", "r.md"]], + ["csdd_skill_add_script", { skill: "s", file: "x.sh", root: "/p" }, ["skill", "add-script", "s", "x.sh", "--root", "/p"]], + ["csdd_skill_add_asset", { skill: "s", file: "a.png" }, ["skill", "add-asset", "s", "a.png"]], + ["csdd_skill_validate", { name: "s" }, ["skill", "validate", "s"]], + ["csdd_skill_delete", { name: "s", force: true }, ["skill", "delete", "s", "--force"]], + + // agent + ["csdd_agent_create", { name: "rev", description: "d" }, ["agent", "create", "rev", "--description", "d"]], + [ + "csdd_agent_create", + { name: "rev", description: "d", tools: ["Read", "Grep"], model: "opus", title: "Rev", force: true, root: "/p" }, + ["agent", "create", "rev", "--description", "d", "--tools", "Read", "--tools", "Grep", "--model", "opus", "--title", "Rev", "--force", "--root", "/p"], + ], + [ + "csdd_agent_create", + { name: "rev", description: "d", effort: "high" }, + ["agent", "create", "rev", "--description", "d", "--effort", "high"], + ], + [ + "csdd_agent_create", + { name: "rev", description: "d", model: "opus", effort: "max" }, + ["agent", "create", "rev", "--description", "d", "--model", "opus", "--effort", "max"], + ], + ["csdd_agent_list", {}, ["agent", "list"]], + ["csdd_agent_show", { name: "rev" }, ["agent", "show", "rev"]], + ["csdd_agent_delete", { name: "rev", force: true }, ["agent", "delete", "rev", "--force"]], +]; + +for (const [name, params, expected] of CASES) { + test(`toArgs ${name} ${JSON.stringify(params)}`, () => { + assert.deepEqual(argv(name, params), expected); + }); +} + +// --- registry invariants --------------------------------------------------- + +test("every registered tool is exercised by the CASES table", () => { + const covered = new Set(CASES.map(([name]) => name)); + const missing = allTools.map((t) => t.name).filter((n) => !covered.has(n)); + assert.deepEqual(missing, [], "tools missing a toArgs test case"); +}); + +test("tool names are unique", () => { + const names = allTools.map((t) => t.name); + assert.equal(new Set(names).size, names.length); +}); + +test("every tool has the required, well-formed fields", () => { + for (const t of allTools) { + assert.match(t.name, /^csdd(_[a-z]+)+$/, `name shape: ${t.name}`); + assert.ok(t.title && t.title.length > 0, `${t.name} has a title`); + assert.ok(t.description && t.description.length > 0, `${t.name} has a description`); + assert.equal(typeof t.inputSchema, "object", `${t.name} inputSchema is an object`); + assert.equal(typeof t.toArgs, "function", `${t.name} toArgs is a function`); + } +}); + +test("miscTools are included in allTools", () => { + for (const m of miscTools) { + assert.ok(byName.has(m.name), `${m.name} present in allTools`); + } +}); + +test("the first argv token is always a known csdd resource/command", () => { + const roots = new Set(["version", "steering", "spec", "skill", "agent"]); + for (const t of allTools) { + // Call with empty params; we only inspect the leading command token, which + // never depends on user input. + const head = t.toArgs({})[0]; + assert.ok(roots.has(head), `${t.name} -> ${head}`); + } +}); diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json index c8d4cb4..42561d4 100644 --- a/mcp-server/tsconfig.json +++ b/mcp-server/tsconfig.json @@ -1,17 +1,17 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": false, - "sourceMap": false, - "resolveJsonModule": true - }, - "include": ["src/**/*.ts"] -} +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/npm/csdd/README.md b/npm/csdd/README.md index 5ef065a..85850df 100644 --- a/npm/csdd/README.md +++ b/npm/csdd/README.md @@ -1,41 +1,41 @@ -# csdd - -**Claude Spec-Driven Development — as an executable contract.** - -`csdd` is a single Go binary that turns the Spec-Driven Development (SDD) -workflow for [Claude Code](https://claude.com/claude-code) into a contract that -is validated mechanically — for humans *and* AI agents. - -## Run - -No install needed — `npx` fetches the right prebuilt binary for your platform: - -```bash -npx @protonspy/csdd --help -npx @protonspy/csdd # interactive TUI -``` - -Prefer the short `csdd` command on your `PATH`? Install it globally: - -```bash -npm install -g @protonspy/csdd # then: csdd -``` - -This package ships a thin launcher; the native binary for your platform is -pulled in automatically as an optional dependency (no postinstall scripts, no -download at install time). Prebuilt for linux, macOS, and Windows on x64/arm64. - -## Usage - -```bash -npx @protonspy/csdd # interactive TUI -npx @protonspy/csdd spec generate photo-albums --artifact requirements # headless / CI -``` - -> Installed globally? Drop the `npx @protonspy/` prefix and just call `csdd`. - -See the [full documentation](https://github.com/protonspy/csdd#readme). - -## License - -[Apache-2.0](https://github.com/protonspy/csdd/blob/main/LICENSE) +# csdd + +**Claude Spec-Driven Development — as an executable contract.** + +`csdd` is a single Go binary that turns the Spec-Driven Development (SDD) +workflow for [Claude Code](https://claude.com/claude-code) into a contract that +is validated mechanically — for humans *and* AI agents. + +## Run + +No install needed — `npx` fetches the right prebuilt binary for your platform: + +```bash +npx @protonspy/csdd --help +npx @protonspy/csdd # interactive TUI +``` + +Prefer the short `csdd` command on your `PATH`? Install it globally: + +```bash +npm install -g @protonspy/csdd # then: csdd +``` + +This package ships a thin launcher; the native binary for your platform is +pulled in automatically as an optional dependency (no postinstall scripts, no +download at install time). Prebuilt for linux, macOS, and Windows on x64/arm64. + +## Usage + +```bash +npx @protonspy/csdd # interactive TUI +npx @protonspy/csdd spec generate photo-albums --artifact requirements # headless / CI +``` + +> Installed globally? Drop the `npx @protonspy/` prefix and just call `csdd`. + +See the [full documentation](https://github.com/protonspy/csdd#readme). + +## License + +[Apache-2.0](https://github.com/protonspy/csdd/blob/main/LICENSE) diff --git a/npm/csdd/bin/csdd.js b/npm/csdd/bin/csdd.js index 1e200f0..25432ef 100644 --- a/npm/csdd/bin/csdd.js +++ b/npm/csdd/bin/csdd.js @@ -1,81 +1,85 @@ -#!/usr/bin/env node -// Launcher for the csdd CLI distributed via npm. -// -// The actual Go binary ships in a per-platform optional dependency -// (@protonspy/csdd--). npm installs only the package whose -// "os"/"cpu" match the host, so this shim just resolves that package's binary -// and execs it — forwarding argv, stdio, signals, and the exit code. No -// postinstall, no network at install time. -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); - -const PLATFORM = { darwin: "darwin", linux: "linux", win32: "win32" }[process.platform]; -const ARCH = { x64: "x64", arm64: "arm64" }[process.arch]; - -if (!PLATFORM || !ARCH) { - console.error( - `csdd: unsupported platform ${process.platform}/${process.arch}. ` + - `Prebuilt binaries are available for linux, macOS, and Windows on x64/arm64.` - ); - process.exit(1); -} - -const pkg = `@protonspy/csdd-${PLATFORM}-${ARCH}`; -const binName = process.platform === "win32" ? "csdd.exe" : "csdd"; - -let binPath; -try { - binPath = require.resolve(`${pkg}/bin/${binName}`); -} catch { - console.error( - `csdd: could not find the native binary for ${PLATFORM}-${ARCH}.\n` + - `The optional dependency "${pkg}" was not installed.\n` + - `Reinstall without --no-optional / --ignore-optional, or report an issue at\n` + - `https://github.com/protonspy/csdd/issues` - ); - process.exit(1); -} - -// When invoked via `npx @protonspy/csdd` (which is `npm exec` under the hood), -// echo that exact spelling in the binary's --help / usage output. A global -// install runs this same launcher as the bare `csdd` command — there npm is not -// in the picture (npm_command is unset), so the binary keeps its default name. -// An explicit CSDD_PROG always wins. -const env = { ...process.env }; -if (!env.CSDD_PROG) { - const argv1 = process.argv[1] || ""; - const viaNpx = - process.env.npm_command === "exec" || - argv1.includes("/_npx/") || - argv1.includes("\\_npx\\"); - if (viaNpx) env.CSDD_PROG = "npx @protonspy/csdd"; -} - -const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit", env }); - -// Forward terminating signals so the TUI shuts down cleanly. -for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) { - process.on(sig, () => { - try { - child.kill(sig); - } catch { - /* child already gone */ - } - }); -} - -child.on("error", (err) => { - console.error(`csdd: failed to launch binary: ${err.message}`); - process.exit(1); -}); - -child.on("exit", (code, signal) => { - if (signal) { - // Re-raise the signal so the parent exit status reflects it. - process.kill(process.pid, signal); - } else { - process.exit(code ?? 0); - } -}); +#!/usr/bin/env node +// Launcher for the csdd CLI distributed via npm. +// +// The actual Go binary ships in a per-platform optional dependency +// (@protonspy/csdd--). npm installs only the package whose +// "os"/"cpu" match the host, so this shim just resolves that package's binary +// and execs it — forwarding argv, stdio, signals, and the exit code. No +// postinstall, no network at install time. +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const PLATFORM = { darwin: "darwin", linux: "linux", win32: "win32" }[process.platform]; +const ARCH = { x64: "x64", arm64: "arm64" }[process.arch]; + +if (!PLATFORM || !ARCH) { + console.error( + `csdd: unsupported platform ${process.platform}/${process.arch}. ` + + `Prebuilt binaries are available for linux, macOS, and Windows on x64/arm64.` + ); + process.exit(1); +} + +const pkg = `@protonspy/csdd-${PLATFORM}-${ARCH}`; +const binName = process.platform === "win32" ? "csdd.exe" : "csdd"; + +let binPath; +try { + binPath = require.resolve(`${pkg}/bin/${binName}`); +} catch { + console.error( + `csdd: could not find the native binary for ${PLATFORM}-${ARCH}.\n` + + `The optional dependency "${pkg}" was not installed.\n` + + `Reinstall without --no-optional / --ignore-optional, or report an issue at\n` + + `https://github.com/protonspy/csdd/issues` + ); + process.exit(1); +} + +// When invoked via `npx @protonspy/csdd` (which is `npm exec` under the hood), +// echo that exact spelling in the binary's --help / usage output. A global +// install runs this same launcher as the bare `csdd` command — there npm is not +// in the picture (npm_command is unset), so the binary keeps its default name. +// An explicit CSDD_PROG always wins. +const env = { ...process.env }; +if (!env.CSDD_PROG) { + const argv1 = process.argv[1] || ""; + const viaNpx = + process.env.npm_command === "exec" || + argv1.includes("/_npx/") || + argv1.includes("\\_npx\\"); + if (viaNpx) env.CSDD_PROG = "npx @protonspy/csdd"; +} + +const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit", env }); + +// Forward terminating signals so the TUI shuts down cleanly. +for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(sig, () => { + try { + child.kill(sig); + } catch { + /* child already gone */ + } + }); +} + +child.on("error", (err) => { + console.error(`csdd: failed to launch binary: ${err.message}`); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + // Re-raise the signal so the parent's exit status reflects it. Remove our own + // handler for that signal first, otherwise the forwarding listener installed + // above would intercept the re-raise and the wrapper would exit 0 instead of + // dying from the signal. + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else { + process.exit(code ?? 0); + } +}); diff --git a/npm/csdd/package.json b/npm/csdd/package.json index 3cb761b..1b01337 100644 --- a/npm/csdd/package.json +++ b/npm/csdd/package.json @@ -1,41 +1,41 @@ -{ - "name": "@protonspy/csdd", - "version": "0.0.0", - "description": "Claude Spec-Driven Development — a single Go binary that turns the SDD workflow into a mechanically validated contract for humans and AI agents.", - "keywords": [ - "claude", - "claude-code", - "spec-driven-development", - "sdd", - "cli", - "ai", - "agents" - ], - "homepage": "https://github.com/protonspy/csdd#readme", - "bugs": { - "url": "https://github.com/protonspy/csdd/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/protonspy/csdd.git" - }, - "license": "Apache-2.0", - "type": "module", - "bin": { - "csdd": "bin/csdd.js" - }, - "files": [ - "bin/csdd.js", - "README.md" - ], - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@protonspy/csdd-linux-x64": "0.0.0", - "@protonspy/csdd-linux-arm64": "0.0.0", - "@protonspy/csdd-darwin-x64": "0.0.0", - "@protonspy/csdd-darwin-arm64": "0.0.0", - "@protonspy/csdd-win32-x64": "0.0.0" - } -} +{ + "name": "@protonspy/csdd", + "version": "0.0.0", + "description": "Claude Spec-Driven Development — a single Go binary that turns the SDD workflow into a mechanically validated contract for humans and AI agents.", + "keywords": [ + "claude", + "claude-code", + "spec-driven-development", + "sdd", + "cli", + "ai", + "agents" + ], + "homepage": "https://github.com/protonspy/csdd#readme", + "bugs": { + "url": "https://github.com/protonspy/csdd/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/protonspy/csdd.git" + }, + "license": "Apache-2.0", + "type": "module", + "bin": { + "csdd": "bin/csdd.js" + }, + "files": [ + "bin/csdd.js", + "README.md" + ], + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@protonspy/csdd-linux-x64": "0.0.0", + "@protonspy/csdd-linux-arm64": "0.0.0", + "@protonspy/csdd-darwin-x64": "0.0.0", + "@protonspy/csdd-darwin-arm64": "0.0.0", + "@protonspy/csdd-win32-x64": "0.0.0" + } +} diff --git a/npm/scripts/build-packages.mjs b/npm/scripts/build-packages.mjs index 5f9d199..1ec530f 100644 --- a/npm/scripts/build-packages.mjs +++ b/npm/scripts/build-packages.mjs @@ -1,164 +1,168 @@ -#!/usr/bin/env node -// Assemble the npm publish tree from the release artifacts. -// -// Usage: node npm/scripts/build-packages.mjs [artifactsDir] -// -// the release tag, e.g. "v1.2.3" (the leading "v" is stripped -// for the npm version; the raw tag is used to locate the -// artifact tarballs produced by the release workflow). -// [artifactsDir] directory holding csdd___.{tar.gz,zip} -// (default: "artifacts"). -// -// Output: npm/dist/ -// csdd/ root package (launcher + optionalDependencies) -// csdd--/ one per target, carrying the native binary -// -// The Go binaries are reused as-is from the release artifacts (they already -// carry the version baked in via -ldflags), so the npm binary is byte-identical -// to the GitHub release binary. -import { execFileSync } from "node:child_process"; -import { - chmodSync, - cpSync, - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const SCOPE = "@protonspy"; - -// Go target -> npm platform/arch + os/cpu constraints. -const TARGETS = [ - { goos: "linux", goarch: "amd64", node: "linux-x64", os: "linux", cpu: "x64" }, - { goos: "linux", goarch: "arm64", node: "linux-arm64", os: "linux", cpu: "arm64" }, - { goos: "darwin", goarch: "amd64", node: "darwin-x64", os: "darwin", cpu: "x64" }, - { goos: "darwin", goarch: "arm64", node: "darwin-arm64", os: "darwin", cpu: "arm64" }, - { goos: "windows", goarch: "amd64", node: "win32-x64", os: "win32", cpu: "x64" }, -]; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const npmDir = resolve(scriptDir, ".."); -const repoRoot = resolve(npmDir, ".."); - -const tag = process.argv[2]; -if (!tag) { - console.error("error: missing argument (e.g. v1.2.3)"); - process.exit(1); -} -const version = tag.replace(/^v/, ""); -const artifactsDir = resolve(process.argv[3] ?? join(repoRoot, "artifacts")); -const outDir = join(npmDir, "dist"); - -// Preflight: every platform artifact must exist before we touch npm/dist, so a -// version mismatch (e.g. `npm-build VERSION=v0.1.2` against v0.1.1 tarballs) -// fails with a clear message instead of a cryptic tar/unzip error — and never -// wipes a good npm/dist or leaves a half-built one that breaks `npm-publish`. -const missingArtifacts = TARGETS.map((t) => { - const ext = t.goos === "windows" ? "zip" : "tar.gz"; - return join(artifactsDir, `csdd_${tag}_${t.goos}_${t.goarch}.${ext}`); -}).filter((f) => !existsSync(f)); -if (missingArtifacts.length > 0) { - console.error( - `error: missing release artifacts for ${tag} in ${artifactsDir}:\n` + - missingArtifacts.map((f) => " " + f).join("\n") + - `\nbuild them first: make dist VERSION=${tag}` - ); - process.exit(1); -} - -rmSync(outDir, { recursive: true, force: true }); -mkdirSync(outDir, { recursive: true }); - -const repository = { - type: "git", - url: "git+https://github.com/protonspy/csdd.git", -}; - -// --- per-platform packages ------------------------------------------------- -const optionalDependencies = {}; -for (const t of TARGETS) { - const pkgName = `${SCOPE}/csdd-${t.node}`; - const binName = t.goos === "windows" ? "csdd.exe" : "csdd"; - const pkgDir = join(outDir, `csdd-${t.node}`); - const binDir = join(pkgDir, "bin"); - mkdirSync(binDir, { recursive: true }); - - const base = `csdd_${tag}_${t.goos}_${t.goarch}`; - if (t.goos === "windows") { - execFileSync("unzip", ["-o", join(artifactsDir, `${base}.zip`), "-d", binDir], { - stdio: "inherit", - }); - } else { - execFileSync("tar", ["-xzf", join(artifactsDir, `${base}.tar.gz`), "-C", binDir], { - stdio: "inherit", - }); - chmodSync(join(binDir, binName), 0o755); - } - - writeFileSync( - join(pkgDir, "package.json"), - JSON.stringify( - { - name: pkgName, - version, - description: `csdd native binary for ${t.node}`, - repository, - license: "Apache-2.0", - os: [t.os], - cpu: [t.cpu], - files: [`bin/${binName}`], - }, - null, - 2 - ) + "\n" - ); - optionalDependencies[pkgName] = version; - console.log(`built ${pkgName}@${version}`); -} - -// --- root package ---------------------------------------------------------- -const rootSrc = join(npmDir, "csdd"); -const rootOut = join(outDir, "csdd"); -mkdirSync(join(rootOut, "bin"), { recursive: true }); -cpSync(join(rootSrc, "bin", "csdd.js"), join(rootOut, "bin", "csdd.js")); -cpSync(join(rootSrc, "README.md"), join(rootOut, "README.md")); - -const rootPkg = JSON.parse(readFileSync(join(rootSrc, "package.json"), "utf8")); -rootPkg.version = version; -rootPkg.optionalDependencies = optionalDependencies; -writeFileSync(join(rootOut, "package.json"), JSON.stringify(rootPkg, null, 2) + "\n"); -console.log(`built ${rootPkg.name}@${version}`); - -// --- mcp-server package ---------------------------------------------------- -// Ship @protonspy/csdd-mcp in lockstep with the CLI: stamp it to the same -// release version and pin the per-platform csdd binaries it resolves to that -// exact version, then stage its pre-built dist/ for publish. Build it first: -// npm --prefix mcp-server ci && npm --prefix mcp-server run build -const mcpSrc = join(repoRoot, "mcp-server"); -const mcpDist = join(mcpSrc, "dist"); -if (!existsSync(mcpDist)) { - console.error( - "error: mcp-server/dist not found — build it first:\n" + - " npm --prefix mcp-server ci && npm --prefix mcp-server run build\n" + - " (or `make mcp-dist`)" - ); - process.exit(1); -} -const mcpOut = join(outDir, "csdd-mcp"); -mkdirSync(mcpOut, { recursive: true }); -cpSync(mcpDist, join(mcpOut, "dist"), { recursive: true }); -cpSync(join(mcpSrc, "README.md"), join(mcpOut, "README.md")); - -const mcpPkg = JSON.parse(readFileSync(join(mcpSrc, "package.json"), "utf8")); -mcpPkg.version = version; -for (const k of Object.keys(mcpPkg.optionalDependencies ?? {})) { - mcpPkg.optionalDependencies[k] = version; // exact, in lockstep with the binary -} -delete mcpPkg.devDependencies; // not needed by consumers of the published package -delete mcpPkg.scripts; // prepublishOnly would re-run tsc against a src/ we don't ship -writeFileSync(join(mcpOut, "package.json"), JSON.stringify(mcpPkg, null, 2) + "\n"); -console.log(`built ${mcpPkg.name}@${version}`); +#!/usr/bin/env node +// Assemble the npm publish tree from the release artifacts. +// +// Usage: node npm/scripts/build-packages.mjs [artifactsDir] +// +// the release tag, e.g. "v1.2.3" (the leading "v" is stripped +// for the npm version; the raw tag is used to locate the +// artifact tarballs produced by the release workflow). +// [artifactsDir] directory holding csdd___.{tar.gz,zip} +// (default: "artifacts"). +// +// Output: npm/dist/ +// csdd/ root package (launcher + optionalDependencies) +// csdd--/ one per target, carrying the native binary +// +// The Go binaries are reused as-is from the release artifacts (they already +// carry the version baked in via -ldflags), so the npm binary is byte-identical +// to the GitHub release binary. +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCOPE = "@protonspy"; + +// Go target -> npm platform/arch + os/cpu constraints. +// Must match the Makefile PLATFORMS and the release.yml build matrix. The root +// package's optionalDependencies are generated from this list, so adding a +// target here emits its platform package and wires it up automatically. +const TARGETS = [ + { goos: "linux", goarch: "amd64", node: "linux-x64", os: "linux", cpu: "x64" }, + { goos: "linux", goarch: "arm64", node: "linux-arm64", os: "linux", cpu: "arm64" }, + { goos: "darwin", goarch: "amd64", node: "darwin-x64", os: "darwin", cpu: "x64" }, + { goos: "darwin", goarch: "arm64", node: "darwin-arm64", os: "darwin", cpu: "arm64" }, + { goos: "windows", goarch: "amd64", node: "win32-x64", os: "win32", cpu: "x64" }, + { goos: "windows", goarch: "arm64", node: "win32-arm64", os: "win32", cpu: "arm64" }, +]; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const npmDir = resolve(scriptDir, ".."); +const repoRoot = resolve(npmDir, ".."); + +const tag = process.argv[2]; +if (!tag) { + console.error("error: missing argument (e.g. v1.2.3)"); + process.exit(1); +} +const version = tag.replace(/^v/, ""); +const artifactsDir = resolve(process.argv[3] ?? join(repoRoot, "artifacts")); +const outDir = join(npmDir, "dist"); + +// Preflight: every platform artifact must exist before we touch npm/dist, so a +// version mismatch (e.g. `npm-build VERSION=v0.1.2` against v0.1.1 tarballs) +// fails with a clear message instead of a cryptic tar/unzip error — and never +// wipes a good npm/dist or leaves a half-built one that breaks `npm-publish`. +const missingArtifacts = TARGETS.map((t) => { + const ext = t.goos === "windows" ? "zip" : "tar.gz"; + return join(artifactsDir, `csdd_${tag}_${t.goos}_${t.goarch}.${ext}`); +}).filter((f) => !existsSync(f)); +if (missingArtifacts.length > 0) { + console.error( + `error: missing release artifacts for ${tag} in ${artifactsDir}:\n` + + missingArtifacts.map((f) => " " + f).join("\n") + + `\nbuild them first: make dist VERSION=${tag}` + ); + process.exit(1); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const repository = { + type: "git", + url: "git+https://github.com/protonspy/csdd.git", +}; + +// --- per-platform packages ------------------------------------------------- +const optionalDependencies = {}; +for (const t of TARGETS) { + const pkgName = `${SCOPE}/csdd-${t.node}`; + const binName = t.goos === "windows" ? "csdd.exe" : "csdd"; + const pkgDir = join(outDir, `csdd-${t.node}`); + const binDir = join(pkgDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + const base = `csdd_${tag}_${t.goos}_${t.goarch}`; + if (t.goos === "windows") { + execFileSync("unzip", ["-o", join(artifactsDir, `${base}.zip`), "-d", binDir], { + stdio: "inherit", + }); + } else { + execFileSync("tar", ["-xzf", join(artifactsDir, `${base}.tar.gz`), "-C", binDir], { + stdio: "inherit", + }); + chmodSync(join(binDir, binName), 0o755); + } + + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify( + { + name: pkgName, + version, + description: `csdd native binary for ${t.node}`, + repository, + license: "Apache-2.0", + os: [t.os], + cpu: [t.cpu], + files: [`bin/${binName}`], + }, + null, + 2 + ) + "\n" + ); + optionalDependencies[pkgName] = version; + console.log(`built ${pkgName}@${version}`); +} + +// --- root package ---------------------------------------------------------- +const rootSrc = join(npmDir, "csdd"); +const rootOut = join(outDir, "csdd"); +mkdirSync(join(rootOut, "bin"), { recursive: true }); +cpSync(join(rootSrc, "bin", "csdd.js"), join(rootOut, "bin", "csdd.js")); +cpSync(join(rootSrc, "README.md"), join(rootOut, "README.md")); + +const rootPkg = JSON.parse(readFileSync(join(rootSrc, "package.json"), "utf8")); +rootPkg.version = version; +rootPkg.optionalDependencies = optionalDependencies; +writeFileSync(join(rootOut, "package.json"), JSON.stringify(rootPkg, null, 2) + "\n"); +console.log(`built ${rootPkg.name}@${version}`); + +// --- mcp-server package ---------------------------------------------------- +// Ship @protonspy/csdd-mcp in lockstep with the CLI: stamp it to the same +// release version and pin the per-platform csdd binaries it resolves to that +// exact version, then stage its pre-built dist/ for publish. Build it first: +// npm --prefix mcp-server ci && npm --prefix mcp-server run build +const mcpSrc = join(repoRoot, "mcp-server"); +const mcpDist = join(mcpSrc, "dist"); +if (!existsSync(mcpDist)) { + console.error( + "error: mcp-server/dist not found — build it first:\n" + + " npm --prefix mcp-server ci && npm --prefix mcp-server run build\n" + + " (or `make mcp-dist`)" + ); + process.exit(1); +} +const mcpOut = join(outDir, "csdd-mcp"); +mkdirSync(mcpOut, { recursive: true }); +cpSync(mcpDist, join(mcpOut, "dist"), { recursive: true }); +cpSync(join(mcpSrc, "README.md"), join(mcpOut, "README.md")); + +const mcpPkg = JSON.parse(readFileSync(join(mcpSrc, "package.json"), "utf8")); +mcpPkg.version = version; +for (const k of Object.keys(mcpPkg.optionalDependencies ?? {})) { + mcpPkg.optionalDependencies[k] = version; // exact, in lockstep with the binary +} +delete mcpPkg.devDependencies; // not needed by consumers of the published package +delete mcpPkg.scripts; // prepublishOnly would re-run tsc against a src/ we don't ship +writeFileSync(join(mcpOut, "package.json"), JSON.stringify(mcpPkg, null, 2) + "\n"); +console.log(`built ${mcpPkg.name}@${version}`);